| Current Path : /var/www/html/reddsis/docs/ |
| Current File : /var/www/html/reddsis/docs/CMS-BUILDER-GUIDE.md |
# CMS Builder — Panduan Lengkap untuk Developer
> Dokumentasi teknikal untuk sistem FrontendSite CMS Builder.
> Rujukan cepat faham arsitektur, component rendering, content modules, menu, routing, dan seeding.
>
> **Auth Pages:** Sistem autentikasi frontend user (login, register, forgot/reset password, email verification) dibina sebagai CMS Pages. Rujuk [CMS-AUTH-SYSTEM.md](./CMS-AUTH-SYSTEM.md).
---
## 1. Konsep Asas
CMS Builder membolehkan pembinaan website **dynamic tanpa coding** melalui 3 entiti utama:
| Entiti | Table | Fungsi |
|--------|-------|--------|
| **Component** | `frontend_components` | Blok content boleh guna semula (Blade template) |
| **Site** | `frontend_sites` | Website dengan header, footer, layout tersendiri |
| **Page** | `frontend_pages` | Halaman dalam site, mengandungi komponen |
### 3 Langkah Setup
```
Step 1: Buat Component → Step 2: Buat Site → Step 3: Buat Page
↓
Attach component ke page
```
#### Step 1: Buat Component
Admin panel: `/admin/frontend-site/components/create`
| Field | Contoh | Penerangan |
|-------|--------|------------|
| `code` | `hero_banner` | Unik, jadi variable `$hero_banner` dalam Blade |
| `name` | Hero Banner | Nama paparan |
| `type` | `PHP` / `HTML` / `JS` / `CSS` | `PHP` jika guna query DB |
| `category` | `Header` / `Section` / `Content` / `Footer` / `Panel` / `Modal` | Kumpulan komponen |
| `content` | Blade template | Content sebenar komponen |
#### Step 2: Buat Site
Admin panel: `/admin/frontend-site/sites/create`
| Field | Penerangan |
|-------|------------|
| `name` | Nama website |
| `slug` | URL segment (unique) |
| `header_fk` | Pilih component untuk header (contoh: `navbar`) |
| `footer_fk` | Pilih component untuk footer (contoh: `footer`) |
| `layout` | Blade layout yang wrap header + page + footer |
| `is_default` | Site utama bila access `/` |
#### Step 3: Buat Page & Attach Component
Admin panel: `/admin/frontend-site/pages/create`
1. Pilih `site`
2. Isi `name`, `slug`
3. Attach component — tick component dalam list, set `sort_order`
4. Isi `page_content` (optional) — content utama page, guna `$hero_banner`, `$features` dll
5. Save → `BladeSyncService::syncAll()` akan sync ke storage
---
## 2. Styling — CSS
Ada 3 tempat boleh letak CSS:
### 2.1 Dalam Component (Inline)
Letak `<style>` terus dalam `content` component:
```blade
<style>
.cms-hero { background: linear-gradient(135deg, #0f172a, #1e293b); padding: 6rem 2rem; text-align: center; color: white; }
.cms-hero h1 { font-size: 3rem; margin: 0; }
</style>
<section class="cms-hero">
<h1>Welcome to {{ $siteName }}</h1>
</section>
```
### 2.2 Dalam Site Layout
Letak `<style>` dalam field `layout` site, CSS dikongsi semua page:
```blade
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #1e293b; }
</style>
{!! $__header__ !!}
<main>{!! $__page__ !!}</main>
{!! $__footer__ !!}
```
### 2.3 Dalam Page Layout (Custom)
Tandakan `use_custom_layout = true` pada page, layout guna `LAYOUTS/PAGE/{siteSlug}/{slug}.blade.php`:
```
STORAGE PATH:
LAYOUTS/SITE/{site-slug}.blade.php ← Site layout (default)
LAYOUTS/PAGE/{site-slug}/{slug}.blade.php ← Page layout (custom)
```
### CSS Classes Standard (Guna dalam Component)
```css
.cms-section { padding: 4rem 2rem; font-family: sans-serif; }
.cms-section--alt { background: #f8fafc; }
.cms-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem; max-width: 1200px; margin: 0 auto; }
.cms-grid--sm { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; }
.cms-card { background: white; border-radius: 1rem; overflow: hidden; border: 1px solid #e2e8f0; transition: 0.3s; text-decoration: none; color: inherit; display: block; }
.cms-card:hover { box-shadow: 0 10px 40px rgba(0,0,0,0.08); transform: translateY(-2px); }
.cms-card-img { width: 100%; height: 200px; object-fit: cover; display: block; }
.cms-thumb { width: 100%; height: 120px; object-fit: cover; border-radius: 0.75rem; border: 1px solid #e2e8f0; cursor: pointer; transition: 0.3s; }
.cms-thumb:hover { transform: scale(1.05); }
.cms-download-item { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.5rem; background: white; border-radius: 0.75rem; border: 1px solid #e2e8f0; text-decoration: none; color: inherit; transition: 0.2s; }
.cms-download-item:hover { border-color: #3b82f6; }
.cms-slide { border-radius: 1rem; overflow: hidden; position: relative; height: 250px; background-size: cover; background-position: center; display: flex; align-items: flex-end; padding: 2rem; color: white; }
```
---
## 3. Navbar & Menu — $menuTree
### 3.1 $menuTree — Bagaimana Ia Dihasilkan
`PortalHandler::handle()` akan:
```
1. Query RoleMapping:
→ role_code = 'public'
→ category = 'navbar'
→ site_id = current site
2. Bina recursive tree berdasarkan parent_id → menu_id:
$buildTree = function($parentId) use (&$buildTree, $mappings) {
$branch = [];
foreach ($mappings as $mapping) {
if ($mapping->parent_id == $parentId) {
$item = $mapping->menu->toArray();
$children = $buildTree($mapping->menu_id);
if ($children) $item['children'] = $children;
$branch[] = $item;
}
}
return $branch;
};
$menuTree = $buildTree(0);
3. Pass ke semua component/page/layout sebagai:
$vars['menuTree'] = $menuTree
```
Struktur `$menuTree`:
```php
[
[
'menu_name' => 'About',
'menu_link' => '/about',
'menu_icon' => null,
'menu_id' => 1,
'children' => [
[
'menu_name' => 'Team',
'menu_link' => '/team',
'children' => [],
],
],
],
[
'menu_name' => 'Contact',
'menu_link' => '/contact',
'children' => [],
],
]
```
### 3.2 Manual Menu vs Auto Fallback
```
ADA role_mapping untuk public/navbar?
├── YA → $menuTree = nested tree dari frontend_menu + frontend_role_mapping
└── TIDAK → $menuTree = flat list semua pages dalam site (auto fallback)
```
**Manual Menu:** Admin create di `Frontend Menu` → `Assign Menu` → pilih role `public`, category `main-navbar`.
**Auto Fallback:** Jika tiada langsung `RoleMapping` untuk `public`/`navbar`, system auto guna semua `FrontendPage` dalam site sebagai flat menu.
### 3.3 Link Auto-Prefix
Link `/about` auto jadi `/{siteSlug}/about` dalam component:
```blade
@php
$link = $item['menu_link'] ?? '#';
if (str_starts_with($link, '/') && !str_starts_with($link, '//') && !filter_var($link, FILTER_VALIDATE_URL)) {
$link = '/' . $siteSlug . $link;
}
@endphp
<a href="{{ $link }}">{{ $item['menu_name'] }}</a>
```
### 3.4 Component Navbar — Contoh Lengkap
```blade
@php
$buildLink = function($link) use ($siteSlug) {
if (str_starts_with($link, '/') && !str_starts_with($link, '//') && !filter_var($link, FILTER_VALIDATE_URL))
return '/' . $siteSlug . $link;
return $link;
};
@endphp
<nav style="background:#0f172a;padding:0 2rem;display:flex;align-items:center;justify-content:space-between;height:70px;">
<a href="/{{ $siteSlug }}" style="color:white;font-size:1.25rem;font-weight:700;text-decoration:none;">{{ $siteName }}</a>
<div style="display:flex;gap:0.5rem;">
@foreach($menuTree as $item)
<div style="position:relative;"
onmouseover="var d=this.querySelector('.dropdown');if(d)d.style.display='block';"
onmouseout="var d=this.querySelector('.dropdown');if(d)d.style.display='none';">
<a href="{{ $buildLink($item['menu_link']) }}" style="color:#cbd5e1;text-decoration:none;padding:0.5rem 1rem;">
{{ $item['menu_name'] }}
</a>
@if(!empty($item['children']))
<div class="dropdown" style="display:none;position:absolute;top:100%;left:0;background:#1e293b;min-width:180px;">
@foreach($item['children'] as $child)
<a href="{{ $buildLink($child['menu_link']) }}" style="display:block;color:#94a3b8;padding:0.4rem 0.75rem;">
{{ $child['menu_name'] }}
</a>
@endforeach
</div>
@endif
</div>
@endforeach
</div>
</nav>
```
### 3.5 Variable Tersedia dalam Komponen
| Variable | Type | Sumber |
|----------|------|--------|
| `$siteSlug` | string | `$site->slug` |
| `$siteName` | string | `$site->name` |
| `$sitePages` | collection | Semua page dalam site |
| `$menus` | collection | Flat list `Menu` models |
| `$menuTree` | array | Nested menu (parent-child) |
| `$__entry__` | string | Output entry script (rendered) |
| `$__header__` | string | Rendered header HTML |
| `$__page__` | string | Rendered page content HTML |
| `$__footer__` | string | Rendered footer HTML |
| `$_shared` | stdClass | Object untuk sharing data antara komponen |
---
## 4. Content Modules — Cara Query 7 Jenis Content
Untuk papar data content dalam component, guna `@php` dalam component content (type: PHP).
### Pattern Asas
```blade
@php
$items = \App\Models\Backend\ContentArticle::with('translations')
->where('article_status', 'ACTIVE')
->latest()
->take(3)
->get();
@endphp
@if($items->count())
<section class="cms-section">
<h2>Latest News</h2>
@foreach($items as $item)
@php
$t = $item->translations->firstWhere('article_translation_main', 1)
?? $item->translations->first();
@endphp
<div class="cms-card">
<h3>{{ $t?->article_translation_title ?? 'Untitled' }}</h3>
</div>
@endforeach
</section>
@endif
```
### 4.1 ContentArticle
**Table:** `content_article`, PK: `article_id`
**Translation:** `content_article_translation`
```blade
@php
$items = \App\Models\Backend\ContentArticle::with('translations')
->where('article_status', 'ACTIVE')
->latest()
->take(3)
->get();
@endphp
@if($items->count())
<section class="cms-section">
<h2>Articles</h2>
<div class="cms-grid">
@foreach($items as $item)
@php
$t = $item->translations->firstWhere('article_translation_main',1) ?? $item->translations->first();
$img = $item->article_image ? (str_starts_with($item->article_image, 'http') ? $item->article_image : Storage::url($item->article_image)) : null;
@endphp
<div class="cms-card">
@if($img)<img src="{{ $img }}" class="cms-card-img">@endif
<div style="padding:1.25rem;">
<small>{{ $item->article_category }}</small>
<h3>{{ $t?->article_translation_title ?? 'Untitled' }}</h3>
<p>{{ Str::limit(strip_tags($t?->article_translation_content ?? ''), 120) }}</p>
</div>
</div>
@endforeach
</div>
</section>
@endif
```
### 4.2 ContentSlider
**Table:** `content_slider`, PK: `slider_id`
**Translation:** `content_slider_translation` (gambar dalam translation)
```blade
@php
$items = \App\Models\Backend\ContentSlider::with('translations')
->where('slider_status', 'ACTIVE')
->latest()
->take(3)
->get();
@endphp
@if($items->count())
<section class="cms-section">
<h2>Sliders</h2>
@foreach($items as $item)
@php
$t = $item->translations->firstWhere('slider_translation_main',1) ?? $item->translations->first();
$img = $t?->slider_translation_img ? (str_starts_with($t->slider_translation_img, 'http') ? $t->slider_translation_img : Storage::url($t->slider_translation_img)) : null;
@endphp
<div class="cms-slide" @if($img) style="background-image:url('{{ $img }}')" @endif>
<h3>{{ $t?->slider_translation_title ?? 'Banner' }}</h3>
</div>
@endforeach
</section>
@endif
```
### 4.3 ContentApplication
**Table:** `content_applications`, PK: `application_id`
**Translation:** `content_application_translations`
```blade
@php
$items = \App\Models\Backend\ContentApplication::with('translations')
->where('application_status', 'ACTIVE')
->latest()
->take(3)
->get();
@endphp
@if($items->count())
<section class="cms-section cms-section--alt">
<h2>Applications</h2>
<div class="cms-grid">
@foreach($items as $item)
@php
$t = $item->translations->firstWhere('application_translation_main',1) ?? $item->translations->first();
@endphp
<div class="cms-card" style="padding:1.5rem;">
<h3>{{ $t?->application_translation_title ?? 'Service' }}</h3>
<small>{{ $item->application_cat }}</small>
@if($item->application_url)
<br><a href="{{ $item->application_url }}" target="_blank">Apply Now</a>
@endif
</div>
@endforeach
</div>
</section>
@endif
```
### 4.4 ContentDownload
**Table:** `content_downloads`, PK: `download_id`
**Note:** Translation guna self-referencing `download_parent_id` (bukan table berasingan)
```blade
@php
$items = \App\Models\Backend\ContentDownload::where('download_status', 'ACTIVE')
->latest()
->take(4)
->get();
@endphp
@if($items->count())
<section class="cms-section cms-section--alt">
<h2>Downloads</h2>
@foreach($items as $item)
<a href="{{ $item->download_file ? Storage::url($item->download_file) : '#' }}" class="cms-download-item" style="display:block;margin-bottom:0.5rem;">
<span>{{ $item->download_title ?? 'Document' }}</span>
<small>{{ $item->download_category ?? '' }}</small>
</a>
@endforeach
</section>
@endif
```
### 4.5 ContentImage
**Table:** `content_images`, PK: `image_id`
**Note:** Translation guna self-referencing `image_parent_id`
```blade
@php
$items = \App\Models\Backend\ContentImage::where('image_status', 'ACTIVE')
->latest()
->take(6)
->get();
@endphp
@if($items->count())
<section class="cms-section">
<h2>Images</h2>
<div class="cms-grid cms-grid--sm">
@foreach($items as $item)
@php
$url = $item->image_file ? (str_starts_with($item->image_file, 'http') ? $item->image_file : Storage::url($item->image_file)) : null;
@endphp
@if($url)
<img src="{{ $url }}" class="cms-thumb" alt="{{ $item->image_title ?? '' }}">
@endif
@endforeach
</div>
</section>
@endif
```
### 4.6 ContentPhotoGallery
**Table:** `content_photo_gallery`, PK: `gallery_id`
**Translation:** `content_photo_gallery_translations`
**Child Photos:** `content_photo_list` (FK: `photo_gallery_id`)
```blade
@php
$items = \App\Models\Backend\ContentPhotoGallery::with('translations', 'photoLists')
->where('gallery_status', 'ACTIVE')
->latest()
->take(4)
->get();
@endphp
@if($items->count())
<section class="cms-section cms-section--alt">
<h2>Photo Galleries</h2>
<div class="cms-grid">
@foreach($items as $item)
@php
$t = $item->translations->firstWhere('gallery_translation_main',1) ?? $item->translations->first();
$thumb = $item->gallery_thumbnail
? (str_starts_with($item->gallery_thumbnail, 'http') ? $item->gallery_thumbnail : Storage::url($item->gallery_thumbnail))
: null;
@endphp
<a href="/{{ $siteSlug }}/{{ $item->gallery_code }}" class="cms-card">
@if($thumb)<img src="{{ $thumb }}" class="cms-card-img">@endif
<div style="padding:1.25rem;">
<h3>{{ $t?->gallery_translation_title ?? 'Gallery' }}</h3>
<span>{{ $item->photoLists->count() }} photos</span>
</div>
</a>
@endforeach
</div>
</section>
@endif
```
### 4.7 ContentVideo
**Table:** `content_video`, PK: `video_id`
**Translation:** `content_video_translation`
```blade
@php
$items = \App\Models\Backend\ContentVideo::with('translations')
->where('video_status', 'ACTIVE')
->latest()
->take(3)
->get();
@endphp
@if($items->count())
<section class="cms-section">
<h2>Videos</h2>
<div class="cms-grid">
@foreach($items as $item)
@php
$t = $item->translations->firstWhere('video_translation_main',1) ?? $item->translations->first();
$img = $item->video_img ? (str_starts_with($item->video_img, 'http') ? $item->video_img : Storage::url($item->video_img)) : null;
@endphp
<div class="cms-card">
@if($img)<img src="{{ $img }}" class="cms-card-img">@endif
<div style="padding:1.25rem;">
<h3>{{ $t?->video_translation_title ?? 'Untitled' }}</h3>
@if($item->video_source == 'YOUTUBE' && $item->video_url)
<a href="{{ $item->video_url }}" target="_blank">Watch on YouTube</a>
@endif
</div>
</div>
@endforeach
</div>
</section>
@endif
```
### Image URL Pattern (WAJIB Guna)
Semua field gambar guna pattern ini:
```blade
@php
$img = $item->article_image
? (str_starts_with($item->article_image, 'http')
? $item->article_image
: Storage::url($item->article_image))
: null;
@endphp
```
Sebab: gambar boleh dari **external URL** (picsum.photos) atau **local storage** (upload admin).
---
## 5. Flow Component Render — DB → HTML
```
DATABASE
┌──────────────────────────────┐
│ frontend_components.content │
│ frontend_sites.layout │
│ frontend_pages.layout │
│ frontend_pages.entry_script │
│ frontend_pages.page_content │
└──────────┬───────────────────┘
│ BladeSyncService::syncAll()
▼
STORAGE (storage/app/)
┌──────────────────────────────────────────────────┐
│ PHP/{code}.blade.php ← components │
│ LAYOUTS/SITE/{slug}.blade.php ← site layout │
│ LAYOUTS/PAGE/{site}/{page}.blade.php ← page lay │
│ ENTRY/PAGE/{site}/{page}.blade.php ← entry scr │
│ PAGE_CONTENT/{site}/{page}.blade.php ← page cont │
└──────────────────────────────────────────────────┘
│ PortalHandler::handle()
▼
RENDER PIPELINE (PortalHandler)
┌──────────────────────────────────────────────────┐
│ 1. Load site meta (cache → DB) │
│ 2. Load page (default atau by slug) │
│ 3. Query RoleMapping → build $menuTree │
│ 4. Fallback: pages as flat menu │
│ 5. Render ENTRY script → $__entry__ │
│ 6. Loop components: │
│ Storage::get("PHP/{code}.blade.php") │
│ Blade::render() → ${$comp->code} │
│ 7. Render PAGE_CONTENT → $pageHtml │
│ (guna ${code} variables dalam Blade) │
│ 8. Render HEADER component → $__header__ │
│ 9. Render FOOTER component → $__footer__ │
│ 10. Render LAYOUT (site/page): │
│ Blade::render($layout, [ │
│ '__header__', '__page__', '__footer__' │
│ ]) │
└──────────────────────────────────────────────────┘
│
▼
view('frontend.cms', compact('html'))
│
▼
{!! $html !!}
```
### 5.1 BladeSyncService — DB ke Storage
Dipanggil setiap kali CRUD komponen/site/page:
```php
// Sync semua component content ke storage/app/PHP/{code}.blade.php
BladeSyncService::syncComponents();
// Sync site layout ke storage/app/LAYOUTS/SITE/{slug}.blade.php
BladeSyncService::syncSiteLayouts();
// Sync page custom layout ke storage/app/LAYOUTS/PAGE/{site}/{slug}.blade.php
BladeSyncService::syncPageLayouts();
// Sync entry script ke storage/app/ENTRY/PAGE/{site}/{slug}.blade.php
BladeSyncService::syncEntries();
// Sync page content ke storage/app/PAGE_CONTENT/{site}/{slug}.blade.php
BladeSyncService::syncPageContent();
```
### 5.2 PortalHandler — Render Engine
```php
// Satu-satunya method — render page dalam site
PortalHandler::handle($siteSlug, $pageSlug = null);
// ContentArticle / Gallery / Video detail guna query param dalam component page
// portalHandler->handle($siteSlug, 'articles') dengan ?id={code} dalam component
```
### 5.3 Component Code Jadi Variable
Component dengan `code = hero_banner` akan render dan tersedia sebagai `$hero_banner` dalam page content:
```blade
{{-- Page Content --}}
{!! $hero_banner !!}
{!! $features !!}
```
### 5.4 Entry Script Pattern
Guna entry script untuk preprocessing atau set shared data.
**Cara 1 — Auto-capture (v2):** `@php $var = value` terus auto-carried forward ke component/page content/layout. Tak perlu guna `$_shared` untuk variable ringkas:
```blade
{{-- Entry Script — jalan SEBELUM components di-render --}}
@php
$pageTitle = 'Welcome to Our Site';
$showBanner = true;
$items = App\Models\Backend\ContentArticle::take(3)->get();
@endphp
{{-- Page Content: variables auto-available --}}
<h1>{{ $pageTitle }}</h1>
@if($showBanner) <div class="banner">...</div> @endif
```
**Cara 2 — `$_shared` (pass-by-reference):** Disarankan untuk data yang perlu diubah suai oleh pelbagai komponen:
```blade
@php
$_shared->pageTitle = 'Welcome to Our Site';
$_shared->showBanner = true;
@endphp
{{-- Guna dalam mana-mana component --}}
@if($_shared->showBanner ?? false)
<div class="banner">{{ $_shared->pageTitle }}</div>
@endif
```
### 5.5 Layout Pattern
**Site Layout** (default untuk semua page dalam site):
```blade
<!DOCTYPE html>
<html>
<head><title>{{ $siteName }}</title></head>
<body>
{!! $__header__ !!}
<main class="container">{!! $__page__ !!}</main>
{!! $__footer__ !!}
</body>
</html>
```
**Page Custom Layout** (override site layout bila `use_custom_layout = true`):
```blade
<!DOCTYPE html>
<html>
<head><title>Custom Page</title></head>
<body>
{!! $__header__ !!}
<div class="full-width">{!! $__page__ !!}</div>
{!! $__footer__ !!}
</body>
</html>
```
---
## 6. Auto-Render vs Manual Render
### 6.1 Auto-Render (Tanpa Page/Component)
Tiada modul yang auto-render sebagai dedicated detail page melalui URL path. Semua module content dipapar sebagai **component list** dalam page biasa.
**ContentArticle / Gallery detail** dimuat melalui page component guna query param `?id=`:
| Module | URL Pattern | Kaedah |
|--------|-------------|--------|
| **ContentArticle** | `/{siteSlug}/{pageSlug}?id={article_code}` | Page component query `request()->query('id')` |
| **ContentPhotoGallery** | `/{siteSlug}/{pageSlug}?id={gallery_code}` | Page component query `request()->query('id')` |
| Module lain (Slider, Video, etc.) | — | Component list sahaja |
**Contoh URL:**
```
http://127.0.0.1:8000/new-site/article?id=pelancaran-2026
```
- `article` = page slug
- `?id=pelancaran-2026` = filter ContentArticle by `article_code`
### 6.2 Manual Render (Guna Component)
Semua modul lain **hanya display sebagai component list** dalam page:
| Module | Component | Kaedah |
|--------|-----------|--------|
| ContentSlider | `mod_sliders` | Component type PHP query terus |
| ContentVideo | `mod_videos` | Component type PHP query terus |
| ContentDownload | `mod_downloads` | Component type PHP query terus |
| ContentApplication | `mod_applications` | Component type PHP query terus |
| ContentImage | `mod_images` | Component type PHP query terus |
| ContentPhotoList | `mod_photolist` | Component type PHP query terus |
> Tiada dedicated page/URL untuk slider, video, download, application, image, photo list.
> Ia hanya dipapar dalam page melalui component `mod_*`.
---
## 7. Routing — Macam Mana URL Di-resolve
### 7.1 Route Definitions (routes/web.php:610-618)
```php
Route::prefix('/')->middleware('visitor.log')->group(function () {
Route::get('/', [FrontendSiteRenderController::class, 'default'])->name('home');
Route::get('{site}/{slug}', [FrontendSiteRenderController::class, 'resolve'])
->where('slug', '.*') // slug boleh guna slash
->name('frontend-site.page');
Route::get('{slug}', [FrontendSiteRenderController::class, 'resolve'])
->name('frontend-site.home');
});
```
### 7.2 Resolution Pipeline
**Default site pages (single slug — no site prefix):**
```
URL: /about (default site page — tanpa site slug)
↓
FrontendSiteRenderController::resolve('about')
↓
1. Default site check: FrontendSiteCacheService::getDefaultSite()
→ Cari site dengan is_default = true
↓
2. Adakah 'about' dalam pages default site?
├── YA → PortalHandler::handle('default-site', 'about')
└── TIDAK → proceed ke step 3 (site check)
```
**Non-default site / multi-segment URL:**
```
URL: /module-site/video-tutorial
↓
FrontendSiteRenderController::resolve('module-site', 'video-tutorial')
↓
3. Cache check: FrontendSiteCacheService::get('module-site')
→ Baca storage/app/site-meta.json
→ Cari site dengan slug 'module-site'
↓
4. Site ditemui dalam cache?
├── YA → proceed ke step 5
└── TIDAK → fallback ke HomeController::contentArticle($slug)
↓
5. Page check: Adakah 'video-tutorial' dalam site pages?
├── YA → PortalHandler::handle('module-site', 'video-tutorial')
└── TIDAK → abort(404) (tiada auto-render untuk article/gallery)
```
> **Detail ContentArticle/Gallery** tidak di-render melalui URL path. Guna query param dalam page component: `/{siteSlug}/{pageSlug}?id={code}`
> **Ringkasan URL Pattern:**
> - Default site: `base-url/page-slug` (no site slug needed)
> - Other sites: `base-url/site-slug/page-slug`
> - Backward compatible: `base-url/default-site-slug/page-slug` still works
### 7.3 Cache Layer (FrontendSiteCacheService)
Cache disimpan sebagai JSON di `storage/app/site-meta.json`:
```json
{
"my-site": {
"id": 1,
"name": "My Modern Site",
"slug": "my-site",
"is_default": true,
"header_code": "navbar",
"footer_code": "footer",
"pages": {
"home": { "id": 1, "name": "Home", "is_default": true, "components": ["hero","features"] },
"about": { "id": 2, "name": "About", "is_default": false, "components": ["about_content"] }
}
}
}
```
Cache di-rebuild setiap kali `BladeSyncService::syncAll()` dipanggil (iaitu setiap kali CRUD site/page/component).
---
## 8. Seeder Pattern — Order yang Betul
### 8.1 Order dalam DatabaseSeeder
```php
$this->call([
BackendUserSeeder::class, // 1. Admin user
FrontendUserSeeder::class, // 2. Frontend user
AdminRolePermissionSeeder::class, // 3. Roles & permissions
BackendMenuSeeder::class, // 4. Backend menu
ThemeRuleSeeder::class, // 5. Theme rules
RefDataSeeder::class, // 6. Reference data (categories, statuses)
SettingSeeder::class, // 7. System settings
DataDashboardSeeder::class, // 8. Dashboard widgets
WebSiteBackupSeeder::class, // 9. Components + Sites + Pages + Menus
FrontendAuthPagesSeeder::class, // 10. Auth pages
ContentApplicationSeeder::class, // 11-23. Content data seeders
ContentArticleAboutUsSeeder::class,
ContentArticleFaqSeeder::class,
ContentCalendarSeeder::class,
ContentDirectorySeeder::class,
ContentDownloadSeeder::class,
ContentGallerySeeder::class,
ContentImageSeeder::class,
ContentNewsSeeder::class,
ContentPublicHolidaySeeder::class,
ContentSliderSeeder::class,
ContentTranslationSeeder::class,
ContentVideoSeeder::class,
]);
app(BladeSyncService::class)->syncAll();
```
### 8.2 Dependency Graph
```
BackendUserSeeder (1)
└── independent
FrontendUserSeeder (2)
└── independent
AdminRolePermissionSeeder (3)
└── perlu BackendUserSeeder — assign role super-admin ke user
BackendMenuSeeder (4)
└── perlu AdminRolePermissionSeeder — guna Role model
ThemeRuleSeeder (5)
└── independent
RefDataSeeder (6)
└── independent — create reference codes
SettingSeeder (7)
└── independent
DataDashboardSeeder (8)
└── independent
WebSiteBackupSeeder (9)
└── perlu RefDataSeeder — guna status 'ACTIVE'
└── create: components + sites + pages + menu items + downloads
FrontendAuthPagesSeeder (10)
└── perlu WebSiteBackupSeeder — lookup site
Content*Seeder (11-23)
└── perlu WebSiteBackupSeeder — lookup site/page
└── perlu RefDataSeeder — guna status/category codes
```
### 8.3 Important Notes
1. **WebSiteBackupSeeder** guna `create()` (bukan `updateOrCreate`). Jika run 2 kali tanpa truncate → duplicate entry error.
2. **BladeSyncService::syncAll()** dipanggil di akhir `DatabaseSeeder` — penting untuk ensure component content di-sync ke storage.
3. **Portal Display** column untuk ContentArticle dan ContentPhotoGallery sekarang guna `frontend_sites` (bukan `Ref(PORTAL)`).
4. **Component Categories** dalam `WebSiteBackupSeeder`:
- `navbar_web` → category `Header` (bukan `Navbar`)
- `footer_web` → category `Footer` (bukan `Footer Web`)
- **Sebab:** Category mesti match dengan pilihan dalam form: Header, Footer, Section, Content, Panel, Modal
### 8.4 Seed Command
```bash
# Seed semua
php artisan db:seed
# Refresh total
php artisan migrate:fresh --seed
```
---
## 9. Common Issues & Troubleshooting
### 9.1 Component Tak Muncul di Page
```
Sebab 1: Component belum di-attach ke page
Fix: Edit page → tick component dalam list → Save
Sebab 2: BladeSync belum jalan
Fix: Save semula mana-mana site/component/page → trigger syncAll()
Sebab 3: Component code takde dalam page_content
Fix: Pastikan page_content ada {!! $component_code !!}
```
### 9.2 "Undefined variable $xxx" Error
```
Sebab: Component code tak wujud atau belum di-render
Fix: Pastikan component dengan code 'xxx' telah di-attach ke page
dan component status = active
Variable available:
$siteSlug, $siteName, $sitePages
$menus, $menuTree
$_shared, $__entry__, $__header__, $__page__, $__footer__
${component_code} — untuk setiap component yang di-attach
```
### 9.3 Blank Page / Layout Tak Muncul
```
Sebab: Site layout kosong atau tiada
Fix: 1. Edit site → pastikan field layout tidak kosong
2. Layout mesti ada {!! $__header__ !!}, {!! $__page__ !!}, {!! $__footer__ !!}
```
### 9.4 Menu Tak Muncul di Navbar
```
Sebab 1: Tiada RoleMapping untuk public/navbar
Fix: Admin → Frontend Menu → Assign Menu
→ Role: public → Category: navbar → Add menu items
Sebab 2: Fallback ke pages (jika langsung tiada RoleMapping)
Check: Ada tak frontend_pages untuk site ni?
PortalHandler akan auto fallback ke flat page list
Sebab 3: Site ID tak matching
Fix: Pastikan site_id dalam RoleMapping = site_id semasa
```
### 9.5 Article / Gallery 404 (Auto-Render)
```
Sebab 1: article_portal_category / gallery_portal tak sama dengan site slug
Fix: Edit ContentArticle → Portal Display pilih site yang betul
Sebab 2: Status bukan ACTIVE
Fix: Pastikan article_status / gallery_status = 'ACTIVE'
Sebab 3: Site slug tiada dalam cache
Fix: Save mana-mana site → trigger BladeSyncService → rebuild cache
```
### 9.6 Vite / Asset Tak Loading
```
Sebab: npm run build belum jalan
Fix: npm run build (root) + cd public/tailadmin && npm run build
Check: public/build/manifest.json wujud?
public/tailadmin/dist/ ada?
```
### 9.7 Component Content Tak Update
```
Sebab: BladeSync tak trigger lepas edit component content
Fix: BladeSyncService::syncAll() dipanggil dalam controller setiap CRUD.
Jika manual edit DB, kena trigger sync manual:
php artisan tinker
app(BladeSyncService::class)->syncAll();
```
### 9.8 Duplicate Slug / Code
```
Sebab: frontend_pages slug unique per site
frontend_components code unique global
frontend_sites slug unique global
Fix: Guna validation dalam controller:
'slug' => 'required|unique:frontend_pages,slug,NULL,id,site_fk,'.$site_fk
```
### 9.9 Seeder Error — Duplicate Entry
```
Sebab: ContentModuleSeeder guna create()
Jika run 2 kali → duplicate key error
Fix: php artisan migrate:fresh --seed (reset total)
atau truncate manual table content dulu
```
### 9.10 Cache Site Tak Update
```
Sebab: FrontendSiteCacheService cache (site-meta.json) outdated
Fix: Save mana-mana site → trigger syncAll() → rebuild cache
Atau delete storage/app/site-meta.json (auto-rebuild on next request)
```
---
## 10. Monaco Editor — Popup Modal & Component Integration
Semua editor (Component, Site, Page) guna **popup modal pattern** — bukan inline editor.
### 10.1 Editor Modal Flow
```
Click editor trigger box → Modal opens → Edit code → Save/Close
↓
Editor trigger shows preview (first 800 chars)
```
**Features:**
- **Ctrl+S** — AJAX save (tiada page reload)
- **Escape** — close modal, sync ke textarea
- **Click outside modal** — close modal
- **Format button** — auto-format code (Shift+Alt+F)
- **Language selector** — PHP/HTML/CSS/JavaScript
### 10.2 AJAX Save Pattern (SEBiji macam Component)
**Controller pattern** — ajax() check LEPAS update (bukan sebelum validation):
```php
public function update(Request $request, $id)
{
$page = FrontendPage::findOrFail($id);
$data = $request->validate([...]);
// AJAX modal save hanya update kod, tak sentuh name/slug
$page->update($request->ajax() ? Arr::except($data, ['name', 'slug']) : $data);
app(BladeSyncService::class)->syncAll();
// AJAX check LEPAS update — bukan sebelum validation
if ($request->ajax()) {
return response()->json(['message' => 'Code updated successfully!']);
}
flash()->success('Page updated successfully!');
return redirect()->route('frontend-site.pages.index', [...]);
}
```
**JavaScript pattern** — FormData + `X-Requested-With` header:
```javascript
var form = document.getElementById('page-form');
var formData = new FormData(form);
// Hanya hantar field yang sedang diedit
formData.delete('layout');
formData.delete('entry_script');
formData.delete('page_content');
if (currentEditorType === 'entry') {
formData.set('entry_script', entryInput.value);
} else if (currentEditorType === 'pageContent') {
formData.set('page_content', pageContentInput.value);
} else if (currentEditorType === 'layout') {
formData.set('layout', layoutInput.value);
}
formData.set('_method', 'PUT'); // untuk update
fetch(form.action, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json',
},
})
.then(function (res) { return res.json(); })
.then(function (data) { showToast(data.message || 'Code updated successfully!'); })
.catch(function () { showToast('Failed to save.', true); });
```
**Penting:**
- `ajax()` check mesti LEPAS update — kalau SEBELUM, validation error (422) akan block
- `X-Requested-With: XMLHttpRequest` header wajib — tanpa ni `$request->ajax()` return false
- `_method: PUT` untuk update route — Laravel method spoofing
- AJAX modal save **tidak update** `name` & `slug` — main Save baru update metadata
### 10.2a Create Mode — page_id Coordination
Create form ada hidden field `page_id`.
```
First modal Save → create page → return page_id → set hidden field
Main Save → update page yang sama (tiada duplicate slug error)
```
**Sebab:** Modal Save perlu create page supaya code boleh disimpan. Main Save kemudiannya update page yang sama, bukan create baru.
### 10.2b Pre-editing Validation (Name & Slug)
Sebelum modal boleh dibuka atau disave:
- Editor trigger boxes disable (greyed out) kalau Name/Slug kosong.
- Butang Save dalam modal disable sehingga Name/Slug diisi.
- Kalau user cuba klik/drop tanpa isi:
- Name & Slug border merah + shake animation.
- Toast: *"Please fill in Name and Slug before editing code."*
- Error hilang automatik bila user taip.
### 10.3 Component Panel dalam Modal
Buka modal editor → klik "Components" button dalam toolbar → panel drop down.
**Features:**
- **Search/filter** — taip nama component, terus filter
- **Recently Used** — auto-save guna localStorage (max 6)
- **Drag & drop** — drag chip terus ke editor
- **Drag ghost preview** — nampak nama component semasa drag (hijau)
**Workflow:**
```
Open Modal → Click "Components" → Drag chip → Drop in Editor → Done
↓
Recently Used auto-saved
```
### 10.4 Component Count Badge
Butang "Open Component Panel" ada badge nombor hijau — berapa component dah dipilih.
Update automatik bila tick/untick checkbox.
### 10.5 Editor Trigger → Modal Drop
Drag component dari side drawer → drop pada editor trigger box:
1. Trigger show blue highlight (`.drag-over` class)
2. On drop → auto-open modal → insert component
**CSS:**
```css
#editor-trigger.drag-over {
border-color: #3b82f6 !important;
background: #eff6ff !important;
box-shadow: 0 0 0 3px rgba(59,130,246,0.2);
}
```
---
## Appendix A: Storage Structure
```
storage/app/
├── PHP/{code}.blade.php ← Component templates
├── LAYOUTS/SITE/{slug}.blade.php ← Site layouts
├── LAYOUTS/PAGE/{site}/{page}.blade.php ← Page layouts
├── ENTRY/PAGE/{site}/{page}.blade.php ← Entry scripts
├── PAGE_CONTENT/{site}/{page}.blade.php ← Page content
└── site-meta.json ← Site cache
```
## Appendix B: Service Classes Reference
| Service | File | Fungsi |
|---------|------|--------|
| `BladeSyncService` | `app/Services/BladeSyncService.php` | Sync DB → storage files |
| `PortalHandler` | `app/Services/PortalHandler.php` | Render engine untuk CMS site |
| `FrontendSiteCacheService` | `app/Services/FrontendSiteCacheService.php` | JSON cache site metadata |
| `MenuFilterService` | `app/Services/MenuFilterService.php` | Filter/redirect untuk menu assign |
## Appendix C: Model Reference
| Model | Table | PK |
|-------|-------|----|
| `Backend\FrontendSite` | `frontend_sites` | `id` |
| `Backend\FrontendComponent` | `frontend_components` | `id` |
| `Backend\FrontendPage` | `frontend_pages` | `id` |
| `Backend\FrontendPageComponent` | `frontend_page_components` | `id` (pivot) |
| `Backend\menu\frontend\Menu` | `frontend_menu` | `menu_id` |
| `Backend\menu\frontend\RoleMapping` | `frontend_role_mapping` | `id` |
| `Backend\ContentArticle` | `content_article` | `article_id` |
| `Backend\ContentSlider` | `content_slider` | `slider_id` |
| `Backend\ContentApplication` | `content_applications` | `application_id` |
| `Backend\ContentDownload` | `content_downloads` | `download_id` |
| `Backend\ContentImage` | `content_images` | `image_id` |
| `Backend\ContentPhotoGallery` | `content_photo_gallery` | `gallery_id` |
| `Backend\ContentPhotoList` | `content_photo_list` | `photo_id` |
| `Backend\ContentVideo` | `content_video` | `video_id` |