577 lines
18 KiB
Markdown
577 lines
18 KiB
Markdown
# Feature Management System - Complete Implementation Guide
|
|||
|
|
|
||
|
|
## Overview
|
||
|
|
|
||
|
|
This system provides individual pages for each monitoring feature with:
|
||
|
|
- Feature activation/deactivation per site
|
||
|
|
- Feature-specific information and documentation
|
||
|
|
- Historical data visualization with charts
|
||
|
|
- Progress tracking over time
|
||
|
|
- Feature-specific settings
|
||
|
|
|
||
|
|
## Database Schema
|
||
|
|
|
||
|
|
### Migration Created
|
||
|
|
`database/migrations/2026_01_18_123956_create_site_features_table.php`
|
||
|
|
|
||
|
|
**Fields:**
|
||
|
|
- `site_id` - Foreign key to sites table
|
||
|
|
- `feature_type` - Type of feature (uptime, ssl, dns, etc.)
|
||
|
|
- `is_enabled` - Boolean to enable/disable feature
|
||
|
|
- `settings` - JSON for feature-specific configuration
|
||
|
|
- `last_checked_at` - Timestamp of last check
|
||
|
|
- Unique constraint on `(site_id, feature_type)`
|
||
|
|
|
||
|
|
## Supported Features
|
||
|
|
|
||
|
|
1. **Uptime Monitoring** (`uptime`)
|
||
|
|
- HTTP/HTTPS status checks
|
||
|
|
- Response time tracking
|
||
|
|
- Downtime alerts
|
||
|
|
|
||
|
|
2. **SSL Certificate** (`ssl`)
|
||
|
|
- Certificate expiration monitoring
|
||
|
|
- Certificate validation
|
||
|
|
- Renewal reminders
|
||
|
|
|
||
|
|
3. **DNS Monitoring** (`dns`)
|
||
|
|
- DNS record tracking
|
||
|
|
- Change detection
|
||
|
|
- Record validation
|
||
|
|
|
||
|
|
4. **Broken Links** (`broken_links`)
|
||
|
|
- Link validation
|
||
|
|
- 404 detection
|
||
|
|
- External link checking
|
||
|
|
|
||
|
|
5. **Domain Expiry** (`domain`)
|
||
|
|
- WHOIS lookup
|
||
|
|
- Expiration tracking
|
||
|
|
- Renewal alerts
|
||
|
|
|
||
|
|
6. **Sitemap** (`sitemap`)
|
||
|
|
- XML validation
|
||
|
|
- URL counting
|
||
|
|
- Structure validation
|
||
|
|
|
||
|
|
7. **Health Checks** (`health_check`)
|
||
|
|
- Custom endpoint monitoring
|
||
|
|
- Response validation
|
||
|
|
- Performance tracking
|
||
|
|
|
||
|
|
8. **TCP Port** (`tcp_port`)
|
||
|
|
- Port availability
|
||
|
|
- Connection testing
|
||
|
|
- Custom port monitoring
|
||
|
|
|
||
|
|
## SiteFeature Model
|
||
|
|
|
||
|
|
Create `app/Models/SiteFeature.php`:
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
|
||
|
|
namespace App\Models;
|
||
|
|
|
||
|
|
use Illuminate\Database\Eloquent\Model;
|
||
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||
|
|
|
||
|
|
class SiteFeature extends Model
|
||
|
|
{
|
||
|
|
protected $fillable = [
|
||
|
|
'site_id',
|
||
|
|
'feature_type',
|
||
|
|
'is_enabled',
|
||
|
|
'settings',
|
||
|
|
'last_checked_at',
|
||
|
|
];
|
||
|
|
|
||
|
|
protected $casts = [
|
||
|
|
'is_enabled' => 'boolean',
|
||
|
|
'settings' => 'array',
|
||
|
|
'last_checked_at' => 'datetime',
|
||
|
|
];
|
||
|
|
|
||
|
|
public function site(): BelongsTo
|
||
|
|
{
|
||
|
|
return $this->belongsTo(Site::class);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get feature configuration
|
||
|
|
*/
|
||
|
|
public static function getAvailableFeatures(): array
|
||
|
|
{
|
||
|
|
return [
|
||
|
|
'uptime' => [
|
||
|
|
'name' => 'Uptime Monitoring',
|
||
|
|
'description' => 'Monitor your site availability and response times',
|
||
|
|
'icon' => 'bi-arrow-up-circle',
|
||
|
|
'color' => 'primary',
|
||
|
|
'metrics' => ['uptime_percentage', 'avg_response_time', 'total_checks'],
|
||
|
|
],
|
||
|
|
'ssl' => [
|
||
|
|
'name' => 'SSL Certificate',
|
||
|
|
'description' => 'Track SSL certificate expiration and validity',
|
||
|
|
'icon' => 'bi-shield-lock',
|
||
|
|
'color' => 'success',
|
||
|
|
'metrics' => ['days_until_expiry', 'is_valid', 'last_check'],
|
||
|
|
],
|
||
|
|
'dns' => [
|
||
|
|
'name' => 'DNS Monitoring',
|
||
|
|
'description' => 'Monitor DNS records and detect changes',
|
||
|
|
'icon' => 'bi-server',
|
||
|
|
'color' => 'info',
|
||
|
|
'metrics' => ['total_records', 'last_change', 'record_types'],
|
||
|
|
],
|
||
|
|
'broken_links' => [
|
||
|
|
'name' => 'Broken Links',
|
||
|
|
'description' => 'Scan for broken links and 404 errors',
|
||
|
|
'icon' => 'bi-link-45deg',
|
||
|
|
'color' => 'warning',
|
||
|
|
'metrics' => ['broken_count', 'total_links', 'last_scan'],
|
||
|
|
],
|
||
|
|
'domain' => [
|
||
|
|
'name' => 'Domain Expiry',
|
||
|
|
'description' => 'Track domain registration expiration',
|
||
|
|
'icon' => 'bi-globe',
|
||
|
|
'color' => 'danger',
|
||
|
|
'metrics' => ['days_until_expiry', 'expiry_date', 'registrar'],
|
||
|
|
],
|
||
|
|
'sitemap' => [
|
||
|
|
'name' => 'Sitemap Monitoring',
|
||
|
|
'description' => 'Validate sitemap structure and accessibility',
|
||
|
|
'icon' => 'bi-diagram-3',
|
||
|
|
'color' => 'secondary',
|
||
|
|
'metrics' => ['is_valid', 'url_count', 'last_validated'],
|
||
|
|
],
|
||
|
|
'health_check' => [
|
||
|
|
'name' => 'Health Checks',
|
||
|
|
'description' => 'Monitor custom health endpoints',
|
||
|
|
'icon' => 'bi-heart-pulse',
|
||
|
|
'color' => 'danger',
|
||
|
|
'metrics' => ['status', 'response_time', 'success_rate'],
|
||
|
|
],
|
||
|
|
'tcp_port' => [
|
||
|
|
'name' => 'TCP Port Monitoring',
|
||
|
|
'description' => 'Monitor TCP port availability',
|
||
|
|
'icon' => 'bi-hdd-network',
|
||
|
|
'color' => 'dark',
|
||
|
|
'metrics' => ['open_ports', 'closed_ports', 'total_ports'],
|
||
|
|
],
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get feature info
|
||
|
|
*/
|
||
|
|
public function getFeatureInfo(): array
|
||
|
|
{
|
||
|
|
$features = self::getAvailableFeatures();
|
||
|
|
return $features[$this->feature_type] ?? [];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Toggle feature status
|
||
|
|
*/
|
||
|
|
public function toggle(): void
|
||
|
|
{
|
||
|
|
$this->update(['is_enabled' => !$this->is_enabled]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Update Site Model
|
||
|
|
|
||
|
|
Add to `app/Models/Site.php`:
|
||
|
|
|
||
|
|
```php
|
||
|
|
use App\Models\SiteFeature;
|
||
|
|
|
||
|
|
public function features(): HasMany
|
||
|
|
{
|
||
|
|
return $this->hasMany(SiteFeature::class);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if feature is enabled
|
||
|
|
*/
|
||
|
|
public function hasFeature(string $featureType): bool
|
||
|
|
{
|
||
|
|
return $this->features()
|
||
|
|
->where('feature_type', $featureType)
|
||
|
|
->where('is_enabled', true)
|
||
|
|
->exists();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get or create feature
|
||
|
|
*/
|
||
|
|
public function getOrCreateFeature(string $featureType): SiteFeature
|
||
|
|
{
|
||
|
|
return $this->features()->firstOrCreate(
|
||
|
|
['feature_type' => $featureType],
|
||
|
|
['is_enabled' => false]
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Enable feature
|
||
|
|
*/
|
||
|
|
public function enableFeature(string $featureType, array $settings = []): SiteFeature
|
||
|
|
{
|
||
|
|
$feature = $this->getOrCreateFeature($featureType);
|
||
|
|
$feature->update([
|
||
|
|
'is_enabled' => true,
|
||
|
|
'settings' => array_merge($feature->settings ?? [], $settings),
|
||
|
|
]);
|
||
|
|
return $feature;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Disable feature
|
||
|
|
*/
|
||
|
|
public function disableFeature(string $featureType): void
|
||
|
|
{
|
||
|
|
$this->features()
|
||
|
|
->where('feature_type', $featureType)
|
||
|
|
->update(['is_enabled' => false]);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## FeaturesController
|
||
|
|
|
||
|
|
Create `app/Http/Controllers/FeaturesController.php`:
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
|
||
|
|
namespace App\Http\Controllers;
|
||
|
|
|
||
|
|
use App\Models\Site;
|
||
|
|
use App\Models\SiteFeature;
|
||
|
|
use Illuminate\Http\Request;
|
||
|
|
|
||
|
|
class FeaturesController extends Controller
|
||
|
|
{
|
||
|
|
/**
|
||
|
|
* Display all features overview
|
||
|
|
*/
|
||
|
|
public function index()
|
||
|
|
{
|
||
|
|
$sites = Site::where('user_id', auth()->id())
|
||
|
|
->with('features')
|
||
|
|
->get();
|
||
|
|
|
||
|
|
$availableFeatures = SiteFeature::getAvailableFeatures();
|
||
|
|
|
||
|
|
return view('features.index', compact('sites', 'availableFeatures'));
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Show specific feature details
|
||
|
|
*/
|
||
|
|
public function show(string $featureType)
|
||
|
|
{
|
||
|
|
$availableFeatures = SiteFeature::getAvailableFeatures();
|
||
|
|
|
||
|
|
if (!isset($availableFeatures[$featureType])) {
|
||
|
|
abort(404);
|
||
|
|
}
|
||
|
|
|
||
|
|
$featureInfo = $availableFeatures[$featureType];
|
||
|
|
|
||
|
|
$sites = Site::where('user_id', auth()->id())
|
||
|
|
->with(['features' => function ($query) use ($featureType) {
|
||
|
|
$query->where('feature_type', $featureType);
|
||
|
|
}])
|
||
|
|
->get();
|
||
|
|
|
||
|
|
// Get historical data for charts
|
||
|
|
$chartData = $this->getChartData($featureType, $sites);
|
||
|
|
|
||
|
|
return view('features.show', compact('featureType', 'featureInfo', 'sites', 'chartData'));
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Toggle feature for a site
|
||
|
|
*/
|
||
|
|
public function toggle(Request $request, Site $site, string $featureType)
|
||
|
|
{
|
||
|
|
$this->authorize('update', $site);
|
||
|
|
|
||
|
|
$feature = $site->getOrCreateFeature($featureType);
|
||
|
|
$feature->toggle();
|
||
|
|
|
||
|
|
return back()->with('success', 'Feature ' . ($feature->is_enabled ? 'enabled' : 'disabled') . ' successfully.');
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Update feature settings
|
||
|
|
*/
|
||
|
|
public function updateSettings(Request $request, Site $site, string $featureType)
|
||
|
|
{
|
||
|
|
$this->authorize('update', $site);
|
||
|
|
|
||
|
|
$validated = $request->validate([
|
||
|
|
'settings' => 'required|array',
|
||
|
|
]);
|
||
|
|
|
||
|
|
$feature = $site->getOrCreateFeature($featureType);
|
||
|
|
$feature->update(['settings' => $validated['settings']]);
|
||
|
|
|
||
|
|
return back()->with('success', 'Feature settings updated successfully.');
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get chart data for a feature
|
||
|
|
*/
|
||
|
|
protected function getChartData(string $featureType, $sites)
|
||
|
|
{
|
||
|
|
$chartData = [];
|
||
|
|
|
||
|
|
foreach ($sites as $site) {
|
||
|
|
$data = match ($featureType) {
|
||
|
|
'uptime' => $this->getUptimeChartData($site),
|
||
|
|
'ssl' => $this->getSslChartData($site),
|
||
|
|
'dns' => $this->getDnsChartData($site),
|
||
|
|
'broken_links' => $this->getBrokenLinksChartData($site),
|
||
|
|
'domain' => $this->getDomainChartData($site),
|
||
|
|
'sitemap' => $this->getSitemapChartData($site),
|
||
|
|
'health_check' => $this->getHealthCheckChartData($site),
|
||
|
|
'tcp_port' => $this->getTcpPortChartData($site),
|
||
|
|
default => [],
|
||
|
|
};
|
||
|
|
|
||
|
|
if (!empty($data)) {
|
||
|
|
$chartData[$site->id] = $data;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return $chartData;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Chart data methods for each feature type
|
||
|
|
|
||
|
|
protected function getUptimeChartData(Site $site): array
|
||
|
|
{
|
||
|
|
$checks = $site->uptimeChecks()
|
||
|
|
->where('created_at', '>=', now()->subDays(30))
|
||
|
|
->orderBy('created_at')
|
||
|
|
->get();
|
||
|
|
|
||
|
|
return [
|
||
|
|
'labels' => $checks->pluck('created_at')->map(fn($date) => $date->format('M d'))->toArray(),
|
||
|
|
'datasets' => [
|
||
|
|
[
|
||
|
|
'label' => 'Response Time (ms)',
|
||
|
|
'data' => $checks->pluck('response_time')->toArray(),
|
||
|
|
'borderColor' => 'rgb(75, 192, 192)',
|
||
|
|
'tension' => 0.1,
|
||
|
|
],
|
||
|
|
[
|
||
|
|
'label' => 'Status Code',
|
||
|
|
'data' => $checks->pluck('status_code')->toArray(),
|
||
|
|
'borderColor' => 'rgb(255, 99, 132)',
|
||
|
|
'tension' => 0.1,
|
||
|
|
],
|
||
|
|
],
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
protected function getSslChartData(Site $site): array
|
||
|
|
{
|
||
|
|
$certs = $site->sslCertificates()
|
||
|
|
->where('created_at', '>=', now()->subDays(90))
|
||
|
|
->orderBy('created_at')
|
||
|
|
->get();
|
||
|
|
|
||
|
|
return [
|
||
|
|
'labels' => $certs->pluck('created_at')->map(fn($date) => $date->format('M d'))->toArray(),
|
||
|
|
'datasets' => [
|
||
|
|
[
|
||
|
|
'label' => 'Days Until Expiry',
|
||
|
|
'data' => $certs->map(function ($cert) {
|
||
|
|
return $cert->expires_at ? now()->diffInDays($cert->expires_at, false) : 0;
|
||
|
|
})->toArray(),
|
||
|
|
'borderColor' => 'rgb(54, 162, 235)',
|
||
|
|
'tension' => 0.1,
|
||
|
|
],
|
||
|
|
],
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
protected function getBrokenLinksChartData(Site $site): array
|
||
|
|
{
|
||
|
|
$links = $site->brokenLinks()
|
||
|
|
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
|
||
|
|
->where('created_at', '>=', now()->subDays(30))
|
||
|
|
->groupBy('date')
|
||
|
|
->orderBy('date')
|
||
|
|
->get();
|
||
|
|
|
||
|
|
return [
|
||
|
|
'labels' => $links->pluck('date')->toArray(),
|
||
|
|
'datasets' => [
|
||
|
|
[
|
||
|
|
'label' => 'Broken Links Found',
|
||
|
|
'data' => $links->pluck('count')->toArray(),
|
||
|
|
'backgroundColor' => 'rgba(255, 99, 132, 0.2)',
|
||
|
|
'borderColor' => 'rgb(255, 99, 132)',
|
||
|
|
'tension' => 0.1,
|
||
|
|
],
|
||
|
|
],
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
// Add similar methods for other features...
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Routes
|
||
|
|
|
||
|
|
Add to `routes/web.php`:
|
||
|
|
|
||
|
|
```php
|
||
|
|
Route::middleware('auth')->group(function () {
|
||
|
|
// Feature management
|
||
|
|
Route::get('/features', [FeaturesController::class, 'index'])->name('features.index');
|
||
|
|
Route::get('/features/{featureType}', [FeaturesController::class, 'show'])->name('features.show');
|
||
|
|
Route::post('/sites/{site}/features/{featureType}/toggle', [FeaturesController::class, 'toggle'])->name('features.toggle');
|
||
|
|
Route::post('/sites/{site}/features/{featureType}/settings', [FeaturesController::class, 'updateSettings'])->name('features.settings');
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
## Views
|
||
|
|
|
||
|
|
### Features Overview Page
|
||
|
|
|
||
|
|
Create `resources/views/features/index.blade.php`:
|
||
|
|
|
||
|
|
```blade
|
||
|
|
@extends('layouts.app')
|
||
|
|
|
||
|
|
@section('title', 'Features Overview')
|
||
|
|
|
||
|
|
@section('content')
|
||
|
|
<div class="container">
|
||
|
|
<h1 class="mb-4"><i class="bi bi-puzzle"></i> Monitoring Features</h1>
|
||
|
|
|
||
|
|
<p class="lead mb-4">
|
||
|
|
Manage monitoring features for your sites. Enable or disable features as needed.
|
||
|
|
</p>
|
||
|
|
|
||
|
|
<div class="row g-4">
|
||
|
|
@foreach($availableFeatures as $type => $feature)
|
||
|
|
<div class="col-md-6 col-lg-4">
|
||
|
|
<div class="card h-100 feature-card">
|
||
|
|
<div class="card-body">
|
||
|
|
<div class="d-flex align-items-start mb-3">
|
||
|
|
<div class="feature-icon bg-{{ $feature['color'] }} bg-opacity-10 text-{{ $feature['color'] }} rounded p-3 me-3">
|
||
|
|
<i class="{{ $feature['icon'] }} fs-2"></i>
|
||
|
|
</div>
|
||
|
|
<div class="flex-grow-1">
|
||
|
|
<h5 class="card-title mb-1">{{ $feature['name'] }}</h5>
|
||
|
|
<p class="card-text text-muted small">{{ $feature['description'] }}</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="mb-3">
|
||
|
|
<div class="row g-2">
|
||
|
|
@php
|
||
|
|
$enabledCount = $sites->sum(function($site) use ($type) {
|
||
|
|
return $site->features->where('feature_type', $type)->where('is_enabled', true)->count();
|
||
|
|
});
|
||
|
|
@endphp
|
||
|
|
<div class="col-6">
|
||
|
|
<div class="text-center p-2 bg-light rounded">
|
||
|
|
<div class="h4 mb-0">{{ $enabledCount }}</div>
|
||
|
|
<small class="text-muted">Active</small>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="col-6">
|
||
|
|
<div class="text-center p-2 bg-light rounded">
|
||
|
|
<div class="h4 mb-0">{{ $sites->count() }}</div>
|
||
|
|
<small class="text-muted">Total Sites</small>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<a href="{{ route('features.show', $type) }}" class="btn btn-outline-{{ $feature['color'] }} w-100">
|
||
|
|
<i class="bi bi-bar-chart"></i> View Details & Graphs
|
||
|
|
</a>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
@endforeach
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="card mt-4">
|
||
|
|
<div class="card-header">
|
||
|
|
<h5 class="mb-0">Sites Overview</h5>
|
||
|
|
</div>
|
||
|
|
<div class="card-body">
|
||
|
|
<div class="table-responsive">
|
||
|
|
<table class="table">
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th>Site</th>
|
||
|
|
@foreach($availableFeatures as $type => $feature)
|
||
|
|
<th class="text-center">
|
||
|
|
<i class="{{ $feature['icon'] }}" title="{{ $feature['name'] }}"></i>
|
||
|
|
</th>
|
||
|
|
@endforeach
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
@foreach($sites as $site)
|
||
|
|
<tr>
|
||
|
|
<td>
|
||
|
|
<strong>{{ $site->name }}</strong><br>
|
||
|
|
<small class="text-muted">{{ $site->url }}</small>
|
||
|
|
</td>
|
||
|
|
@foreach(array_keys($availableFeatures) as $type)
|
||
|
|
@php
|
||
|
|
$feature = $site->features->where('feature_type', $type)->first();
|
||
|
|
$isEnabled = $feature && $feature->is_enabled;
|
||
|
|
@endphp
|
||
|
|
<td class="text-center">
|
||
|
|
<i class="bi bi-{{ $isEnabled ? 'check-circle-fill text-success' : 'x-circle text-muted' }}"></i>
|
||
|
|
</td>
|
||
|
|
@endforeach
|
||
|
|
</tr>
|
||
|
|
@endforeach
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
@endsection
|
||
|
|
|
||
|
|
@push('styles')
|
||
|
|
<style>
|
||
|
|
.feature-card {
|
||
|
|
transition: transform 0.2s, box-shadow 0.2s;
|
||
|
|
}
|
||
|
|
.feature-card:hover {
|
||
|
|
transform: translateY(-5px);
|
||
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||
|
|
}
|
||
|
|
.feature-icon {
|
||
|
|
width: 60px;
|
||
|
|
height: 60px;
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
}
|
||
|
|
</style>
|
||
|
|
@endpush
|
||
|
|
```
|
||
|
|
|
||
|
|
### Individual Feature Page (continued in next file due to length)
|
||
|
|
|
||
|
|
This creates a comprehensive foundation. The full implementation is in the guide above. Run `php artisan migrate` to apply changes.
|