| Current Path : /var/www/html/reddsis/docs/ |
| Current File : /var/www/html/reddsis/docs/FRONTEND-COMPONENT-GUIDE.md |
# Frontend Component & Module Integration Guide
> Panduan penggunaan dan pembuatan Frontend Components berdasarkan model content modules.
---
## 1. Ringkasan Perubahan
### 1.1 Portal Display Dropdown
**Controller:** `ContentArticleController`, `ContentPhotoGalleryController`
- Portal Display sebelum: `Ref(PORTAL)` — dropdown dari table `ref`
- Portal Display sekarang: `FrontendSite::where('status', true)` — dropdown dari `frontend_sites`
- **Sebab:** Portal perlu berdasarkan site yang aktif dalam CMS, bukan ref statik
### 1.2 FrontendSiteRenderController — Fallback Chain
**File:** `app/Http/Controllers/FrontendSiteRenderController.php`
```
/ → default site home
/{slug} → default site page OR site home OR content article
/{siteSlug}/{pageSlug} → non-default site page
/{siteSlug}/{slug} → check page → check ContentArticle → check ContentPhotoGallery → 404
```
> **Default site:** Pages accessible directly at `/{pageSlug}` tanpa site slug.
> **Other sites:** Guna format `/{siteSlug}/{pageSlug}`.
> **ContentArticle/Gallery detail:** Dimuat melalui page component guna `request()->query('id')`, bukan auto-render URL path.
### 1.3 PortalHandler — Module Pages
**File:** `app/Services/PortalHandler.php`
| Method | Fungsi |
|--------|--------|
| `handle($siteSlug, $pageSlug)` | Render page biasa dalam site layout |
> **Nota:** Semua module content (ContentArticle, ContentSlider, ContentVideo, dll) dipapar sebagai **component list** dalam page (via `mod_*` component). Tiada dedicated URL path untuk detail item — detail dimuat melalui page component guna `request()->query('id')`.
>
> Contoh URL: `http://127.0.0.1:8000/new-site/article?id=pelancaran-2026`
> - `/{siteSlug}/{pageSlug}` → render page biasa
> - Page component query `?id=...` untuk papar detail
### 1.4 Blade Storage — Per-Site Page Paths
**File:** `app/Services/BladeSyncService.php`
- Sebelum: `LAYOUTS/PAGE/{slug}.blade.php`
- Sekarang: `LAYOUTS/PAGE/{siteSlug}/{slug}.blade.php`
- **Sebab:** Dua site boleh ada page dengan slug sama (cth: "home")
Juga untuk entry scripts: `ENTRY/PAGE/{siteSlug}/{slug}.blade.php`
Perubahan berkaitan di `PortalHandler::handle()` — baca layout guna `LAYOUTS/PAGE/{$siteSlug}/{$page->slug}.blade.php`
### 1.5 Demo Seeders
| Seeder | Fungsi |
|--------|--------|
| `WebSiteBackupSeeder` | Create components + sites + pages + menus |
| `Content*Seeder` (13 seeders) | Seed content data (articles, galleries, videos, etc.) |
### 1.6 Image URL Handling
Semua komponen dan PortalHandler guna pattern:
```php
str_starts_with($url, 'http') ? $url : Storage::url($url)
```
Ini membolehkan guna **external URL** (picsum.photos) dan **local storage path** secara serentak.
---
## 2. Dua Jenis Site
### 2.1 Static Landing Page (`/my-site`)
- Guna **FrontendPage** sebagai content
- Home: hero + features
- About: about_content
- Contact: contact_form
- sesuai untuk laman statik
### 2.2 Module Content Site (`/module-site`)
- Guna **FrontendComponent** jenis PHP untuk query data terus dari DB
- Papar data dari ContentArticle, ContentPhotoGallery, ContentVideo, dll
- Sesuai untuk portal kandungan
---
## 3. Cara Penggunaan Components
### 3.1 Component Categories
| Category | Untuk |
|----------|-------|
| `Header` | Navbar, navigation components |
| `Footer` | Footer, copyright components |
| `Section` | Hero, features, about sections |
| `Content` | Article lists, gallery, video |
| `Panel` | Sidebar, widget panels |
| `Modal` | Popup, dialog components |
> **Penting:** Category mesti match dengan pilihan dalam form. Seeder (`WebSiteBackupSeeder`) guna `Header` untuk navbar dan `Footer` untuk footer.
### 3.2 Komponen Static (HTML/CSS sahaja)
Component guna **Blade template** terus dalam field `content`:
```blade
<section style="padding:2rem;">
<h1>{{ $siteName }}</h1>
<p>Welcome to {{ $siteSlug }}</p>
</section>
```
**Variable yang tersedia:**
| Variable | Description |
|----------|-------------|
| `$siteSlug` | Slug site semasa |
| `$siteName` | Nama site |
| `$sitePages` | List semua pages dalam site |
| `$menus` | Flat list menu items |
| `$menuTree` | Nested menu tree (parent-child) |
| `$__entry__` | Rendered output dari entry script |
| `$__header__` | Rendered header HTML |
| `$__page__` | Rendered page HTML |
| `$__footer__` | Rendered footer HTML |
| `$_shared` | stdClass object — sharing data antara entry script, component, layout |
> **Entry script auto-capture:** Semua variable yang dideclare dengan `@php $var = value` dalam entry script **auto-carried** ke component dan page content. Tak perlu `$_shared` untuk variable ringkas.
### 3.2 Komponen PHP (Query DB)
Component guna **PHP code** dalam field `content` untuk query data:
```blade
@php
$items = \App\Models\Backend\ContentArticle::with('translations')
->where('article_status', 'ACTIVE')
->latest()
->take(3)
->get();
@endphp
@if($items->count())
<section>
@foreach($items as $item)
@php $t = $item->translations->firstWhere('article_translation_main', 1) ?? $item->translations->first(); @endphp
<h3>{{ $t?->article_translation_title ?? 'Untitled' }}</h3>
@endforeach
</section>
@endif
```
---
## 4. Cara Pembuatan Component Baru
### 4.1 Steps
1. **Tulis component** — pergi ke admin `/admin/frontend-site/components` → Add Component
- `Code`: guna underscore, cth: `mod_videos`
- `Name`: Nama display
- `Category`: Header / Footer / Section / Content / Panel / Modal
- `Type`: `PHP` jika guna query DB, `HTML` jika statik
- `Content`: Template Blade (inline styles atau guna CSS classes dari site layout)
2. **Attach ke page** — edit page, pilih component dalam list
3. **Guna dalam editor** — ada tiga cara insert variable component:
- **Checkbox** — tick component, tulis `{!! $code !!}` manual
- **Drag & drop** — drag label komponen (≡ icon) terus ke Monaco editor
- **Autocomplete** — taip `$` dalam editor, pilih dari dropdown suggestion
4. **Auto-tick** — checkbox auto tick bila drag/pilih suggestion. Auto untick bila `{!! $code !!}` dipadam dari editor
5. **Sync** — lepas create/edit, `BladeSyncService::syncAll()` akan simulate component content ke storage
### 4.2 Pattern Component untuk Setiap Module
#### ContentArticle — List + Detail (Query Param)
**Component listing** (letak dalam page content/layout):
```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>
<div class="cms-grid">
@foreach($items as $item)
@php $t = $item->translations->firstWhere('article_translation_main',1) ?? $item->translations->first(); @endphp
<div class="cms-card">
@if($item->article_image)
<img src="{{ str_starts_with($item->article_image, 'http') ? $item->article_image : Storage::url($item->article_image) }}">
@endif
<h3>{{ $t?->article_translation_title ?? 'Untitled' }}</h3>
<p>{{ $item->article_category }}</p>
</div>
@endforeach
</div>
</section>
@endif
```
**Component detail page** (guna query param `?id=` untuk single item):
```blade
@php
$id = request()->query('id');
$items = \App\Models\Backend\ContentArticle::with('translations')
->where('article_status', 'ACTIVE')
->where('article_code', $id)
->latest()
->take(3)
->get();
@endphp
@if($items->count())
<section style="padding:4rem 2rem;font-family:sans-serif;">
<div style="max-width:1100px;margin:0 auto;">
@foreach($items as $item)
@php $t = $item->translations->firstWhere('article_translation_main',1) ?? $item->translations->first(); @endphp
<div style="background:white;border-radius:1rem;overflow:hidden;border:1px solid #e2e8f0;">
@if($item->article_image)
<img src="{{ str_starts_with($item->article_image, 'http') ? $item->article_image : Storage::url($item->article_image) }}" style="width:100%;height:180px;object-fit:cover;display:block;">
@endif
<div style="padding:1.25rem;">
<h3 style="color:#0f172a;font-size:1rem;font-weight:600;margin:0 0 0.5rem;">{{ $t?->article_translation_title ?? 'Untitled' }}</h3>
<p style="color:#64748b;font-size:0.85rem;line-height:1.5;margin:0;">{{ $item->article_category }}</p>
</div>
</div>
@endforeach
</div>
</section>
@endif
```
> **Contoh URL:** `http://127.0.0.1:8000/new-site/article?id=pelancaran-2026`
> - `article` = page slug dalam site
> - `?id=pelancaran-2026` = `article_code` ContentArticle
#### ContentPhotoGallery
```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>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="?id={{ $item->gallery_code }}" class="cms-card">
@if($thumb)
<img src="{{ $thumb }}">
@endif
<h3>{{ $t?->gallery_translation_title ?? 'Gallery' }}</h3>
<span>{{ $item->photoLists->count() }} photos</span>
</a>
@endforeach
</div>
</section>
@endif
```
**Component detail page** (guna query param `?id=` untuk single gallery):
```blade
@php
$id = request()->query('id');
$items = \App\Models\Backend\ContentPhotoGallery::with('translations','photoLists')
->where('gallery_status', 'ACTIVE')
->where('gallery_code', $id)
->get();
@endphp
@if($items->count())
@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
<section style="padding:2rem;font-family:sans-serif;">
<div style="max-width:800px;margin:0 auto;">
@if($thumb)
<img src="{{ $thumb }}" style="width:100%;max-width:400px;border-radius:12px;margin-bottom:1.5rem;">
@endif
<h1 style="font-size:1.5rem;font-weight:700;color:#0f172a;margin-bottom:0.5rem;">{{ $t?->gallery_translation_title ?? 'Gallery' }}</h1>
<p style="color:#64748b;margin-bottom:2rem;">{{ $t?->gallery_translation_descr ?? '' }}</p>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:1rem;">
@foreach($item->photoLists as $photo)
@php $url = $photo->photo_url ? (str_starts_with($photo->photo_url, 'http') ? $photo->photo_url : Storage::url($photo->photo_url)) : '';
$cap = $photo->photo_descr ?? $photo->translations?->first()?->photo_descr ?? ''; @endphp
@if($url)
<div style="border-radius:0.75rem;overflow:hidden;border:1px solid #e2e8f0;">
<img src="{{ $url }}" style="width:100%;height:180px;object-fit:cover;display:block;">
@if($cap) <p style="padding:0.5rem;font-size:0.8rem;color:#64748b;">{{ $cap }}</p> @endif
</div>
@endif
@endforeach
</div>
</div>
</section>
@endforeach
@endif
```
> **Contoh URL:** `http://127.0.0.1:8000/new-site/galleries?id=galeri-2026`
> - `galleries` = page slug
> - `?id=galeri-2026` = `gallery_code` ContentPhotoGallery
#### ContentVideo
```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
<h3>{{ $t?->video_translation_title ?? 'Untitled' }}</h3>
</div>
@endforeach
</div>
</section>
@endif
```
**Component detail page** (guna query param `?id=` untuk single video):
```blade
@php
$id = request()->query('id');
$items = \App\Models\Backend\ContentVideo::with('translations')
->where('video_status', 'ACTIVE')
->where('video_code', $id)
->get();
@endphp
@if($items->count())
@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
<section style="padding:2rem;font-family:sans-serif;">
<div style="max-width:800px;margin:0 auto;">
@if($item->video_url)
<div style="position:relative;padding-bottom:56.25%;margin-bottom:1.5rem;background:#f1f5f9;border-radius:12px;overflow:hidden;">
<iframe src="{{ $item->video_url }}" style="position:absolute;top:0;left:0;width:100%;height:100%;border:none;" allowfullscreen></iframe>
</div>
@elseif($img)
<img src="{{ $img }}" style="width:100%;max-width:600px;border-radius:12px;margin-bottom:1.5rem;">
@endif
<h1 style="font-size:1.5rem;font-weight:700;color:#0f172a;margin-bottom:0.5rem;">{{ $t?->video_translation_title ?? 'Video' }}</h1>
<p style="color:#64748b;">{{ $t?->video_translation_descr ?? '' }}</p>
</div>
</section>
@endforeach
@endif
```
> **Contoh URL:** `http://127.0.0.1:8000/new-site/videos?id=tutorial-2026`
> - `videos` = page slug
> - `?id=tutorial-2026` = `video_code` ContentVideo
#### ContentDownload
```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">
<span>{{ $item->download_title ?? 'Document' }}</span>
<small>{{ $item->download_category ?? '' }}</small>
</a>
@endforeach
</section>
@endif
```
#### ContentApplication
```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">
<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">
<h3>{{ $t?->application_translation_title ?? 'Service' }}</h3>
</div>
@endforeach
</div>
</section>
@endif
```
#### ContentImage
```blade
@php
$items = \App\Models\Backend\ContentImage::where('image_status', 'ACTIVE')->latest()->take(6)->get();
@endphp
@if($items->count())
<section class="cms-section cms-section--alt">
<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">
@endif
@endforeach
</div>
</section>
@endif
```
#### ContentSlider
```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>
<div class="cms-grid">
@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
</div>
</section>
@endif
```
#### ContentPhotoList
```blade
@php
$items = \App\Models\Backend\ContentPhotoList::whereNotNull('photo_url')
->where('photo_main', 1)->latest()->take(6)->get();
@endphp
@if($items->count())
<section class="cms-section cms-section--alt">
<h2>Photos</h2>
<div class="cms-grid cms-grid--sm">
@foreach($items as $item)
@php $url = $item->photo_url ? (str_starts_with($item->photo_url, 'http') ? $item->photo_url : Storage::url($item->photo_url)) : null; @endphp
@if($url)
<img src="{{ $url }}" class="cms-thumb">
@endif
@endforeach
</div>
</section>
@endif
```
---
## 5. CSS Classes (Site Layout)
Tambah dalam `<style>` site layout untuk styling seragam:
```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: 1100px; 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); }
.cms-card-img { width: 100%; height: 180px; 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; transition: 0.2s; }
.cms-download-item:hover { border-color: #3b82f6; }
.cms-slide { border-radius: 1rem; overflow: hidden; position: relative; height: 200px; background: linear-gradient(135deg, #0f172a, #1e293b); background-size: cover; background-position: center; display: flex; align-items: flex-end; padding: 2rem; }
.cms-slide h3 { color: white; font-size: 1.25rem; font-weight: 700; }
```
---
## 6. Content Seeders — Data Demo
**Folder:** `database/seeders/Portal/`
Seeders delete semua data sedia ada dan seed items untuk setiap module.
### Seeders:
| Seeder | Module | Items |
|--------|--------|-------|
| ContentNewsSeeder | ContentArticle | 5 (NEWS) |
| ContentArticleAboutUsSeeder | ContentArticle | 5 (ABOUT_US) |
| ContentArticleFaqSeeder | ContentArticle | 5 (FAQ) |
| ContentCalendarSeeder | ContentCalendar | 5 |
| ContentDirectorySeeder | ContentDirectory | 5 |
| ContentDownloadSeeder | ContentDownload | 5 |
| ContentGallerySeeder | ContentPhotoGallery | 5 (4 photos each) |
| ContentImageSeeder | ContentImage | 5 |
| ContentVideoSeeder | ContentVideo | 5 |
| ContentSliderSeeder | ContentSlider | 5 |
| ContentApplicationSeeder | ContentApplication | 5 |
| ContentTranslationSeeder | ContentTranslation | 5 |
### Categories (proper case):
- **Articles:** News, Event, Article, Blog
- **Videos:** Tutorial
- **Downloads:** Manual, Form, Report, Policy
- **Applications:** Registration, Request, Feedback, Complaint, Enquiry
- **Images:** Logo, Banner, Staff, Infographic, Certificate
- **Galleries:** Event
### Language:
Semua translation guna `'language' => 'ms'` (Bahasa Melayu).
### Run:
```bash
php artisan db:seed
```
---
## 7. Image URL Pattern
Untuk semua field gambar dalam module, guna pattern ni bila display:
```php
str_starts_with($value, 'http') ? $value : Storage::url($value)
```
Ini kerana gambar boleh jadi:
- **External URL:** `https://picsum.photos/seed/...` (dari seeder demo)
- **Local storage:** `content-article/image.jpg` (dari upload admin)
---
## 8. File Rujukan
| File | Fungsi |
|------|--------|
| `app/Services/PortalHandler.php` | Render content dalam site layout |
| `app/Services/BladeSyncService.php` | Sync component/page/site ke storage |
| `app/Services/FrontendSiteCacheService.php` | Cache site meta (site-meta.json) |
| `app/Http/Controllers/FrontendSiteRenderController.php` | Route resolver untuk frontend site |
| `database/seeders/FrontendSiteDemoSeeder.php` | Seeder untuk components + sites |
| `database/seeders/ContentModuleSeeder.php` | Seeder untuk module data (5 items each) |