1. Proje Nedir?
Muzibu Platformu
Muzibu, Türkiye merkezli bir B2B müzik platformudur. İşletmelere (kafe, restoran, mağaza, spor salonu, otel vb.) lisanslı arka plan müziği hizmeti sunar. Soundtrack Your Brand modeline benzer: bireysel kullanıcı yoktur, her kullanıcı bir işletme sahibi veya çalışanıdır.
- Framework: Laravel 11 + stancl/tenancy (multi-tenant)
- Tenant ID: 1001 (Muzibu)
- Tenant DB: tenant_muzibu_1528d0
- Central DB: tuufi_4ekim (tuufi.com)
- PHP: 8.3 (/opt/plesk/php/8.3/bin/php)
- Test domain: mztest.muzibu.com
- Production: muzibu.com
- Admin: Tabler.io + Bootstrap + Livewire
- Frontend: Alpine.js + Tailwind CSS
- Müzik: HLS şifreli stream (AES-128)
- Cache: Redis (tenant prefix ayrı)
- Arama: Meilisearch
- Central: tuufi.com (admin paneli, tenant yönetimi)
- Tenant 1001: muzibu.com (müzik platformu)
- Tenant 2: ixtif.com (endüstriyel — farklı sektör)
- Her tenant kendi DB, kendi storage, kendi Redis prefix
- Migration: hem central hem tenant klasöründe olmalı
- Model connection:
'tenant' - Route: MuzibuServiceProvider domain-specific yükler
- Tenant kontrolü:
tenant()->id === 1001
İş Modeli
B2B Only: Guest/bireysel kullanıcı yok. Herkes bir işletmeye bağlı.
Abonelik: İşletmeler aylık/yıllık abonelik satın alır. Kurumsal hesaplar (MuzibuCorporateAccount) altında branch'ler olabilir.
Sektör: Her işletme bir sektöre aittir (Kafe, Spor Salonu, Otel vb.). İçerik bu sektöre göre kişiselleştirilir.
Cihaz profili: Kötü cihazlar (düşük RAM, eski tablet) yaygın — performans kritik. Müşteriler donma/takılma şikayeti yapıyor.
2. Hedefler ve Motivasyon
Ana Hedef
Muzibu anasayfasını dinamik, kişiselleştirilmiş ve zamana duyarlı bir içerik sistemiyle değiştirmek. Şu anda anasayfa statik: hep aynı playlistler, albümler, radyolar gösteriliyor. Yeni sistemde:
Kritik Kısıtlar
Ne Değişecek?
HomeController: Sabit sorgular (son 10 playlist, son 10 albüm, popüler şarkılar...)
Herkese aynı anasayfa gösteriliyor
Saat/sektör farkı yok
Sayfa yenilenmedikçe içerik değişmez
Greeting/karşılama yok
SmartFeedService: Saat + sektör + iş kurallarına göre filtreleme
Her kullanıcı kendi sektörüne uygun içerik görür
Sabah/öğle/akşam farklı koleksiyonlar
Her saat başı sessiz otomatik güncelleme
"Günaydın, Ahmet!" kişisel karşılama
3. Mimari Genel Bakış
Sistem Akışı
┌──────────────────────────────────────────────────────────────────────┐
│ KULLANICI TARAYICISI │
│ │
│ 1. Sayfa yüklenir → PHP greeting + koleksiyonları render eder │
│ 2. setTimeout (saat başı +1dk) → fetch('/api/feed-partial') │
│ 3. Sunucu yeni saatle HTML döner → innerHTML swap │
│ 4. Müzik kesilmez, sadece #feed-container güncellenir │
│ │
└──────────────┬───────────────────────────────────┬───────────────────┘
│ İlk yükleme │ Saat başı AJAX
▼ ▼
┌──────────────────────────────────────────────────────────────────────┐
│ LARAVEL BACKEND │
│ │
│ HomeController@index /api/feed-partial (closure) │
│ │ │ │
│ └──────────┬───────────────────┘ │
│ ▼ │
│ SmartFeedService │
│ ├── getGreeting(User) → selamlama + isim + sektör │
│ └── getFeed(User) │
│ ├── matchesTimeRules(collection, hour) │
│ ├── matchesSectorRules(collection, user) │
│ └── matchesBusinessRules(collection, user) │
│ │
│ Cache: smart_feed:{userId}:{sectorId}:{hour} (TTL: 300s) │
│ │
└──────────────────────────────┬───────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────┐
│ VERİTABANI (tenant_muzibu_1528d0) │
│ │
│ users ──sector_id──▶ muzibu_sectors │
│ │
│ muzibu_content_collections (koleksiyonlar) │
│ ├── display_rules JSON: {start_hour, end_hour, days, is_always} │
│ ├── sector_rules JSON: {mode, sector_ids, show_to_all} │
│ └── business_rules JSON: {subscription, min_months} │
│ │
│ muzibu_collection_items (polimorfik öğeler) │
│ ├── itemable_type: Playlist | Album | Radio | Genre | Sector │
│ └── itemable_id: ilgili tablonun PK'sı │
│ │
│ Mevcut tablolar: muzibu_playlists, muzibu_albums, muzibu_radios, │
│ muzibu_genres, muzibu_sectors, muzibu_songs... │
└──────────────────────────────────────────────────────────────────────┘
Koleksiyon Kavramı
Koleksiyon = İçerik gruplarını organize eden bir kapsayıcı. Admin panelden oluşturulur. İçine 5 farklı tipte öğe eklenebilir. Hangi saatte, hangi sektöre, hangi abonelik seviyesine gösterileceği JSON kurallarla belirlenir.
4. Kesinleşen Kararlar (14 Madde)
Kullanıcıyla 9 versiyon boyunca tartışılıp kesinleşen kararlar:
users.sector_id FK → muzibu_sectors.sector_id. Kullanıcı profilinden seçer. NULL olabilir.start_hour (int 0-23) ve end_hour (int 0-23) seçer. Farklı koleksiyonlar farklı saat aralıklarına sahip olabilir. is_always: true ise saat yoksayılır./koleksiyon/{slug} grid sayfası açılır. 6 ve altı → buton gizli, yatay scroll yeterli.position (sürükle-bırak, Sortable.js). Koleksiyonlar arası: priority (büyük sayı = önce gösterilir).smart_feed:{userId}:{sectorId}:{hour}, TTL: 300 saniye. Saat değişince key de değişir → otomatik invalidate.GET /koleksiyon/{slug} → Grid görünüm, aynı tıklama kuralları geçerli.setTimeout, her saat başı +1 dakikada tetiklenir. setInterval yok, polling yok. Client RAM: ~8 byte. CPU: sıfır.#feed-container div'i güncellenir. fetch('/api/feed-partial') → sunucu HTML render → innerHTML swap. Page reload yok, müzik kesilmez. İç sayfalarda (playlist detay, albüm detay vb.) hiçbir şey yapılmaz.5. Mevcut Yapı (Değiştirilmeyecek)
Yeni sistem bu mevcut modellerin üzerine inşa edilecek. Bu tablolar/modeller zaten var ve değiştirilmeyecek:
Mevcut Muzibu Modelleri
| Model | Tablo | PK | Translatable | Açıklama |
|---|---|---|---|---|
| Playlist | muzibu_playlists | playlist_id | title, slug, description | Çalma listesi. is_system, is_public, is_radio, is_featured bayrakları var. |
| Album | muzibu_albums | album_id | title, slug, description | Albüm. artist_id FK. songs() ilişkisi. |
| Radio | muzibu_radios | radio_id | title, slug, description | Radyo istasyonu. is_featured. HasPlaylistDistribution trait. |
| Genre | muzibu_genres | genre_id | title, slug, description | Müzik türü. 12 adet seeder'da. songs() ilişkisi. |
| Sector | muzibu_sectors | sector_id | title, slug, description | İş sektörü. 8 adet seeder'da. radios() M2M ilişkisi. |
| Song | muzibu_songs | song_id | title, slug, lyrics | Şarkı. album_id, genre_id FK. HLS encrypted stream. |
| Artist | muzibu_artists | artist_id | title, slug, bio | Sanatçı. albums() ilişkisi. |
Mevcut Sektörler (Seeder)
Not: Bu sektörler "mood" gibi. Gerçek işletme sektörleri (Kafe, Otel, Spor Salonu) admin tarafından eklenecek.
Mevcut HomeController (Değiştirilecek)
Dosya: Modules/Muzibu/App/Http/Controllers/Front/HomeController.php
// Şu anki statik sorgular — YENİ SİSTEMDE KALDIRILACAK:
$homePlaylists = Playlist::active()->system()...limit(10); // cache 5dk
$newReleases = Album::active()...limit(10); // cache 5dk
$popularSongs = Song::active()...orderBy('play_count')...limit(10);
$newSongs = Song::active()...orderBy('created_at')...limit(15);
$genres = Genre::active()...limit(15);
$radios = Radio::active()...limit(10);
$sectors = Sector::active()...limit(10);
$userPlaylists = // kullanıcıya özel, 60s cache
// YENİ SİSTEM:
$smartFeed = app(SmartFeedService::class);
$greeting = $smartFeed->getGreeting($user);
$feed = $smartFeed->getFeed($user);
$msUntilNextHour = ((60 - now()->minute) * 60 - now()->second + 60) * 1000;
User Model (Mevcut Durum)
Dosya: app/Models/User.php
Fillable (mevcut): name, surname, email, password, is_active, phone, bio, device_limit, corporate_account_id, subscription_expires_at, audio_preference...
Eksik olan: sector_id — bu kolon henüz yok, migration ile eklenecek.
Eklenecek relationship:
// User.php'ye eklenecek:
public function sector()
{
if (!$this->isMuzibuTenant()) {
return $this->belongsTo(self::class, 'id')->whereRaw('1=0');
}
return $this->belongsTo(
\Modules\Muzibu\App\Models\Sector::class,
'sector_id',
'sector_id'
);
}
6. Veritabanı Şeması (3 Migration)
Modules/Muzibu/database/migrations/ (central) hem Modules/Muzibu/database/migrations/tenant/ (tenant) altında olmalı. 3 aşamalı onay gerekli. AŞAMA 1 onayı henüz alınmadı.| Kolon | Tip | Default | Index | Konum | Açıklama |
|---|---|---|---|---|---|
| sector_id | BIGINT UNSIGNED | NULL | INDEX + FK | after corporate_account_id | FK → muzibu_sectors.sector_id ON DELETE SET NULL |
Schema::table('users', function (Blueprint $table) {
$table->unsignedBigInteger('sector_id')->nullable()->after('corporate_account_id');
$table->index('sector_id');
$table->foreign('sector_id')
->references('sector_id')
->on('muzibu_sectors')
->onDelete('set null');
});
| Kolon | Tip | Default/Index | Açıklama |
|---|---|---|---|
| collection_id | BIGINT UNSIGNED AUTO | PK | Birincil anahtar |
| title | JSON NOT NULL | — | {"tr": "Sabah Kafe Mix", "en": "Morning Cafe Mix"} |
| slug | JSON NOT NULL | — | {"tr": "sabah-kafe-mix", "en": "morning-cafe-mix"} |
| description | JSON NULL | — | Çoklu dil açıklama |
| type | ENUM | — | 'curated', 'daypart', 'sector', 'featured' |
| icon | VARCHAR(50) NULL | — | FontAwesome sınıfı: "fas fa-coffee" |
| color | VARCHAR(20) NULL | — | Hex renk: "#f59e0b" |
| media_id | BIGINT UNSIGNED NULL | — | Kapak görseli (Spatie Media Library FK) |
| display_rules | JSON NULL | — | Aşağıda detaylı açıklandı |
| sector_rules | JSON NULL | — | Aşağıda detaylı açıklandı |
| business_rules | JSON NULL | — | Aşağıda detaylı açıklandı |
| priority | INT DEFAULT 0 | INDEX | Büyük = önce gösterilir |
| is_active | BOOLEAN DEFAULT 1 | INDEX | Aktif/Pasif toggle |
| is_featured | BOOLEAN DEFAULT 0 | — | Öne çıkan koleksiyon |
| cache_ttl | INT DEFAULT 300 | — | Özel cache süresi (saniye) |
| created_at | TIMESTAMP | — | Oluşturma |
| updated_at | TIMESTAMP | — | Güncelleme |
JSON Kolon Detayları
// display_rules — Zaman kuralları
{
"start_hour": 9, // int 0-23 — 09:00
"end_hour": 14, // int 0-23 — 14:00
"days": ["mon","tue","wed","thu","fri","sat","sun"],
"is_always": false // true → start/end yoksayılır, 7/24 gösterilir
}
// sector_rules — Sektör filtreleme
{
"mode": "include", // "include" veya "exclude"
"sector_ids": [1, 3, 5], // hangi sektörler
"show_to_all": false // true → tüm sektörlere göster
}
// business_rules — İş kuralları
{
"subscription": "premium", // "free", "premium", "corporate", "all"
"min_months": 0 // minimum üyelik süresi (ay)
}
| Kolon | Tip | Default/Index | Açıklama |
|---|---|---|---|
| id | BIGINT UNSIGNED AUTO | PK | Birincil anahtar |
| collection_id | BIGINT UNSIGNED | FK + INDEX | → content_collections.collection_id CASCADE |
| itemable_type | VARCHAR(255) | COMPOSITE | Morph map: 'playlist', 'album', 'radio', 'genre', 'sector' |
| itemable_id | BIGINT UNSIGNED | INDEX | İlgili tablonun PK'sı (playlist_id, album_id, radio_id...) |
| position | INT DEFAULT 0 | INDEX | Sürükle-bırak sıralama (küçük = önce) |
| is_active | BOOLEAN DEFAULT 1 | — | Aktif/Pasif |
| created_at | TIMESTAMP | — | |
| updated_at | TIMESTAMP | — |
(collection_id, itemable_type, itemable_id) — aynı öğe aynı koleksiyona iki kez eklenemez.
Morph Map (AppServiceProvider'da tanımlanacak)
Relation::enforceMorphMap([
'playlist' => \Modules\Muzibu\App\Models\Playlist::class,
'album' => \Modules\Muzibu\App\Models\Album::class,
'radio' => \Modules\Muzibu\App\Models\Radio::class,
'genre' => \Modules\Muzibu\App\Models\Genre::class,
'sector' => \Modules\Muzibu\App\Models\Sector::class,
]);
Örnek Veri
-- muzibu_content_collections
INSERT INTO muzibu_content_collections
(collection_id, title, slug, type, icon, priority, display_rules, sector_rules, is_active) VALUES
(1, '{"tr":"Sabah Kafe Mix","en":"Morning Cafe Mix"}',
'{"tr":"sabah-kafe-mix"}',
'daypart', 'fas fa-coffee', 100,
'{"start_hour":6,"end_hour":12,"days":["mon","tue","wed","thu","fri","sat","sun"],"is_always":false}',
'{"mode":"include","sector_ids":[1,2],"show_to_all":false}',
1),
(2, '{"tr":"Editörün Seçimi","en":"Editor Picks"}',
'{"tr":"editorun-secimi"}',
'curated', 'fas fa-star', 200,
'{"is_always":true}',
'{"show_to_all":true}',
1);
-- muzibu_collection_items (koleksiyon 1 için)
INSERT INTO muzibu_collection_items
(collection_id, itemable_type, itemable_id, position) VALUES
(1, 'radio', 5, 1), -- Jazz FM
(1, 'playlist', 12, 2), -- Sabah Akustik
(1, 'playlist', 45, 3), -- Kahve Molası
(1, 'album', 8, 4); -- Cafe Classics albümü
7. Greeting (Karşılama) Sistemi
Koleksiyonlardan tamamen bağımsız. DB'de saklanmaz, admin ayarı yok. Sadece PHP kodu.
| Saat Aralığı | Selamlama | Örnek Çıktı |
|---|---|---|
| 06:00 — 12:00 | Günaydın | Günaydın, Ahmet! Kafe & Restoran için seçilmiş içerikler |
| 12:00 — 18:00 | İyi günler | İyi günler, Zeynep! Spor Salonu & Fitness için seçilmiş içerikler |
| 18:00 — 22:00 | İyi akşamlar | İyi akşamlar, Mehmet! Otel & Spa için seçilmiş içerikler |
| 22:00 — 06:00 | İyi geceler | İyi geceler, Can! Senin için seçilmiş içerikler |
Sektörü NULL olan kullanıcılara: "Senin için seçilmiş içerikler" gösterilir.
Sector eager load edilir (User::with('sector')), ekstra sorgu yok.
Saat başı yenilemede greeting de otomatik güncellenir (partial'ın içinde).
// SmartFeedService::getGreeting(User $user): array
public function getGreeting(User $user): array
{
$hour = now()->hour;
if ($hour >= 6 && $hour < 12) $greet = 'Günaydın';
elseif ($hour >= 12 && $hour < 18) $greet = 'İyi günler';
elseif ($hour >= 18 && $hour < 22) $greet = 'İyi akşamlar';
else $greet = 'İyi geceler';
$sector = $user->sector?->title; // eager loaded, sorgu yok
return [
'title' => "$greet, {$user->name}!",
'subtitle' => $sector
? "{$sector} için seçilmiş içerikler"
: 'Senin için seçilmiş içerikler',
];
}
8. Zaman Yönetimi (Otomatik Sessiz Yenileme)
Problem
Kullanıcılar sabah siteyi açıp gece kadar yenilemeden bırakıyor. İçerik saate bağlı olduğu için "stale content" problemi var. Çözüm: her saat başı otomatik sessiz güncelleme.
Akış Adımları
(60-29)*60 - saniye + 60 = ~32 dakika → $msUntilNextHour = 1920000 (ms){{ $msUntilNextHour }} olarak basılır.fetch('/api/feed-partial') → sunucu saat 9 ile koleksiyonları filtreler + greeting günceller → HTML partial dönerdocument.getElementById('feed-container').innerHTML = yeniHTMLschedule() fonksiyonu: (60 - dakika) * 60000 - saniye*1000 + 60000 ms sonra tekrar tetikle.Neden Bu Yöntem?
| Yöntem | RAM | CPU | Seçildi mi? | Neden |
|---|---|---|---|---|
| setInterval (1dk polling) | Sürekli | Sürekli | Hayır | Kötü cihazları yorar |
| Banner + tıkla | Az | Az | Hayır | Kullanıcı tıklamayı unutur/geciktirir |
| Cron + Push (WebSocket) | Yüksek | Yüksek | Hayır | Sunucu kaynağı + eski cihaz desteği zayıf |
| Meta refresh | Sıfır | Sıfır | Hayır | Sayfa reload = müzik durur |
| Tek setTimeout + fetch | ~8 byte | Sıfır | EVET | Minimum kaynak, müzik kesilmez, otomatik |
Frontend JS Kodu (Tamamı ~15 satır)
// Sadece anasayfada çalışır. Framework yok. Interval yok.
(function(){
var el = document.getElementById('feed-container');
if (!el) return; // iç sayfalarda #feed-container yok → hiçbir şey yapma
function refresh(){
fetch('/api/feed-partial')
.then(function(r){ return r.text() })
.then(function(html){
el.innerHTML = html;
schedule();
});
}
function schedule(){
var d = new Date();
var ms = (60 - d.getMinutes()) * 60000 - d.getSeconds() * 1000 + 60000;
setTimeout(refresh, ms); // saat başı +1dk
}
setTimeout(refresh, {{ $msUntilNextHour }}); // ilk timeout (PHP hesaplar)
})();
{{ $msUntilNextHour }} = Blade değişkeni. PHP'de: $ms = ((60 - now()->minute) * 60 - now()->second + 60) * 1000;
/) — #feed-container div'i varsa9. UX Kuralları
Tıklama Davranışları
| İçerik Tipi | Tıklanınca Ne Olur | Sayfa Değişir mi? | Route |
|---|---|---|---|
| Radyo | Hemen çalar | Hayır — player bar güncellenir | JS: playerCore.playRadio(id) |
| Playlist | Şarkı listesi açılır | Evet | /playlists/{slug} |
| Albüm | Şarkı listesi açılır | Evet | /albums/{slug} |
| Genre | Genre sayfası | Evet | /genres/{slug} |
| Sektör | Sektör sayfası | Evet | /sectors/{slug} |
"Tümü" Butonu Kuralı
/koleksiyon/{slug} grid sayfası açılır.Anasayfa Wireframe
┌─────────────────────────────────────────────┐ │ Header | Arama | Profil │ ← dokunulmaz ├─────────────────────────────────────────────┤ │ Player Bar ▶ Şarkı Adı — Sanatçı │ ← dokunulmaz ├═════════════════════════════════════════════┤ │ │ │ ☀️ Günaydın, Ahmet! │ ← greeting │ Kafe & Restoran için seçilmiş içerikler │ ← sektör │ │ │ ★ Editörün Seçimi Tümü › │ ← priority:200 │ [Radio][Playl][Album][Playl][Genre]→ │ ← yatay scroll │ │ │ ☕ Sabah Kafe Mix │ ← priority:100 │ [Radio][Playl][Playl][Album] → │ ← 4 öğe, Tümü yok │ │ │ 🎵 Türkçe Pop Klasikler Tümü › │ ← 9 öğe, Tümü var │ [Radio][Playl][Album][Playl][Genre]→ │ │ │ ├═════════════════════════════════════════════┤ │ Footer │ ← dokunulmaz └─────────────────────────────────────────────┘ ↑ #feed-container — sadece bu bölge güncellenir (greeting + koleksiyonlar) Header, Player Bar, Footer sabit kalır
10. Backend Kod Mimarisi
ContentCollection Model
Dosya: Modules/Muzibu/App/Models/ContentCollection.php
class ContentCollection extends BaseModel
{
use HasTranslations, Sluggable, HasMediaManagement, SoftDeletes;
protected $connection = 'tenant';
protected $table = 'muzibu_content_collections';
protected $primaryKey = 'collection_id';
public $translatable = ['title', 'slug', 'description'];
protected $fillable = [
'title', 'slug', 'description', 'type', 'icon', 'color',
'media_id', 'display_rules', 'sector_rules', 'business_rules',
'priority', 'is_active', 'is_featured', 'cache_ttl',
];
protected $casts = [
'display_rules' => 'array',
'sector_rules' => 'array',
'business_rules' => 'array',
'priority' => 'integer',
'is_active' => 'boolean',
'is_featured' => 'boolean',
'cache_ttl' => 'integer',
];
// Koleksiyondaki öğeler (polimorfik)
public function items(): HasMany
{
return $this->hasMany(CollectionItem::class, 'collection_id', 'collection_id')
->orderBy('position');
}
// Aktif öğeler
public function activeItems(): HasMany
{
return $this->items()->where('is_active', true);
}
// Scope: aktif koleksiyonlar
public function scopeActive($q) { return $q->where('is_active', true); }
}
CollectionItem Model
Dosya: Modules/Muzibu/App/Models/CollectionItem.php
class CollectionItem extends Model
{
protected $connection = 'tenant';
protected $table = 'muzibu_collection_items';
protected $fillable = [
'collection_id', 'itemable_type', 'itemable_id', 'position', 'is_active',
];
protected $casts = [
'position' => 'integer',
'is_active' => 'boolean',
];
// Polimorfik ilişki — Playlist, Album, Radio, Genre veya Sector
public function itemable(): MorphTo
{
return $this->morphTo();
}
public function collection(): BelongsTo
{
return $this->belongsTo(ContentCollection::class, 'collection_id', 'collection_id');
}
}
SmartFeedService
Dosya: Modules/Muzibu/App/Services/SmartFeedService.php
class SmartFeedService
{
// Greeting (yukarıda detaylı açıklandı)
public function getGreeting(User $user): array { /* ... */ }
// Ana feed — filtrelenmiş koleksiyonlar
public function getFeed(User $user): Collection
{
$hour = now()->hour;
$sectorId = $user->sector_id;
$cacheKey = "smart_feed:{$user->id}:{$sectorId}:{$hour}";
return Cache::remember($cacheKey, 300, function() use ($user, $hour) {
return ContentCollection::active()
->with(['activeItems.itemable']) // eager load
->orderByDesc('priority')
->get()
->filter(fn($c) => $this->matchesTimeRules($c, $hour))
->filter(fn($c) => $this->matchesSectorRules($c, $user))
->filter(fn($c) => $this->matchesBusinessRules($c, $user));
});
}
// Filtre 1: Zaman kuralı
private function matchesTimeRules(ContentCollection $c, int $hour): bool
{
$rules = $c->display_rules;
if (empty($rules) || ($rules['is_always'] ?? true)) return true;
$start = $rules['start_hour'] ?? 0;
$end = $rules['end_hour'] ?? 23;
// Gece geçişi: start=22, end=6 → 22,23,0,1,2,3,4,5
if ($start <= $end) {
return $hour >= $start && $hour < $end;
}
return $hour >= $start || $hour < $end;
}
// Filtre 2: Sektör kuralı
private function matchesSectorRules(ContentCollection $c, User $user): bool
{
$rules = $c->sector_rules;
if (empty($rules) || ($rules['show_to_all'] ?? true)) return true;
$sectorId = $user->sector_id;
$sectorIds = $rules['sector_ids'] ?? [];
$mode = $rules['mode'] ?? 'include';
if ($mode === 'include') return in_array($sectorId, $sectorIds);
if ($mode === 'exclude') return !in_array($sectorId, $sectorIds);
return true;
}
// Filtre 3: İş kuralı
private function matchesBusinessRules(ContentCollection $c, User $user): bool
{
$rules = $c->business_rules;
if (empty($rules)) return true;
$sub = $rules['subscription'] ?? 'all';
if ($sub !== 'all') {
// premium/corporate kontrol
if ($sub === 'premium' && !$user->isPremium()) return false;
if ($sub === 'corporate' && !$user->isCorporateMember()) return false;
}
return true;
}
}
/api/feed-partial Endpoint
// routes/web.php (Muzibu domain group içinde)
Route::get('/api/feed-partial', function () {
$user = auth()->user();
if (!$user) abort(401);
$service = app(SmartFeedService::class);
$greeting = $service->getGreeting($user);
$feed = $service->getFeed($user);
return view('themes.muzibu.partials.feed-collections', compact('greeting', 'feed'));
})->middleware(['auth'])->name('muzibu.feed.partial');
11. Admin Paneli
Liste Sayfası (CollectionComponent)
Route: /admin/koleksiyonlar — Livewire component
Tablo kolonları:
Filtreler: Tip, sektör, durum. Toplu işlemler: sil, aktif/pasif. Mevcut pattern: Modules/Page/.../page-component.blade.php referans.
Form Sayfası (CollectionManageComponent — 5 Tab)
Route: /admin/koleksiyonlar/manage/{id?}
is_always checkbox → true ise diğerleri gizlenir.start_hour dropdown: 00:00, 01:00, 02:00 ... 23:00 (24 seçenek, sadece düz saat)end_hour dropdown: aynı formatGünler: 7 checkbox (Pzt-Paz), hepsi seçili varsayılan.
show_to_all checkbox → true ise diğerleri gizlenir.mode radio: include / excludeSektör seçimi: dual listbox (mevcut admin pattern'i).
subscription dropdown: Tümü, Free, Premium, Corporate.min_months number input: minimum üyelik süresi.
Her öğenin yanında: tip badge + başlık + sil butonu.
12. Frontend
feed-collections.blade.php (AJAX Partial)
Dosya: resources/views/themes/muzibu/partials/feed-collections.blade.php
Bu dosya hem ilk sayfa yüklemesinde hem saat başı AJAX ile yüklenir. Bağımsız partial — layout/header/footer içermez.
<!-- Greeting -->
<div class="mb-6">
<h2 class="text-2xl font-bold text-white">{{ $greeting['title'] }}</h2>
<p class="text-slate-400">{{ $greeting['subtitle'] }}</p>
</div>
<!-- Koleksiyonlar -->
@foreach($feed as $collection)
<div class="mb-8">
<div class="flex justify-between items-center mb-3">
<h3 class="text-lg font-bold text-white">
@if($collection->icon)
<i class="{{ $collection->icon }} mr-2"></i>
@endif
{{ $collection->title }}
</h3>
@if($collection->activeItems->count() > 6)
<a href="/koleksiyon/{{ $collection->slug }}" class="text-blue-400">
Tümü ›
</a>
@endif
</div>
<div class="flex gap-4 overflow-x-auto pb-2">
@foreach($collection->activeItems as $item)
{{-- Her itemable_type'a göre farklı kart --}}
@include('themes.muzibu.partials.collection-item-card', [
'item' => $item->itemable,
'type' => $item->itemable_type
])
@endforeach
</div>
</div>
@endforeach
index.blade.php (Anasayfa) Güncelleme
Dosya: resources/views/themes/muzibu/index.blade.php
<!-- Mevcut statik içerik KALDIRILACAK -->
<!-- Yerine: -->
<div id="feed-container">
@include('themes.muzibu.partials.feed-collections', [
'greeting' => $greeting,
'feed' => $feed,
])
</div>
@push('scripts')
<script>
(function(){
var el = document.getElementById('feed-container');
if (!el) return;
function refresh(){
fetch('/api/feed-partial')
.then(function(r){ return r.text() })
.then(function(html){ el.innerHTML = html; schedule(); });
}
function schedule(){
var d = new Date();
var ms = (60 - d.getMinutes()) * 60000 - d.getSeconds() * 1000 + 60000;
setTimeout(refresh, ms);
}
setTimeout(refresh, {{ $msUntilNextHour }});
})();
</script>
@endpush
collection/show.blade.php (Grid Sayfa)
"Tümü" tıklanınca açılan sayfa. Route: /koleksiyon/{slug}
Koleksiyonun tüm öğelerini grid layout ile gösterir. Aynı tıklama kuralları geçerli (radio = hemen çal, diğerleri = sayfa aç).
13. Dosya & Route Haritası
Oluşturulacak / Güncellenecek Dosyalar
YENİ DOSYALAR: Modules/Muzibu/ ├── database/migrations/ │ ├── 2026_02_24_000001_add_sector_id_to_users.php ← M1 central │ ├── 2026_02_24_000002_create_content_collections.php ← M2 central │ ├── 2026_02_24_000003_create_collection_items.php ← M3 central │ └── tenant/ │ ├── 2026_02_24_000001_add_sector_id_to_users.php ← M1 tenant │ ├── 2026_02_24_000002_create_content_collections.php ← M2 tenant │ └── 2026_02_24_000003_create_collection_items.php ← M3 tenant ├── App/Models/ │ ├── ContentCollection.php ← YENİ │ └── CollectionItem.php ← YENİ ├── App/Services/ │ └── SmartFeedService.php ← YENİ ├── App/Http/Controllers/Front/ │ └── CollectionController.php ← YENİ ├── App/Http/Livewire/Admin/ │ ├── CollectionComponent.php ← YENİ │ └── CollectionManageComponent.php ← YENİ └── resources/views/livewire/admin/ ├── collection-component.blade.php ← YENİ └── collection-manage-component.blade.php ← YENİ resources/views/themes/muzibu/ ├── partials/ │ ├── feed-collections.blade.php ← YENİ (AJAX partial) │ └── collection-item-card.blade.php ← YENİ (kart component) └── collection/ └── show.blade.php ← YENİ (Tümü sayfası) GÜNCELLENECEK DOSYALAR: app/Models/User.php ← sector() relationship + $fillable Modules/Muzibu/App/Http/Controllers/Front/HomeController.php ← SmartFeedService entegrasyonu Modules/Muzibu/routes/web.php ← /koleksiyon/{slug} + /api/feed-partial Modules/Muzibu/Providers/MuzibuServiceProvider.php ← Livewire component register + morph map resources/views/themes/muzibu/index.blade.php ← #feed-container + setTimeout JS
Route Tablosu
| Method | URI | Handler | Middleware | Açıklama |
|---|---|---|---|---|
| GET | /koleksiyon/{slug} | CollectionController@show | auth | Koleksiyon grid sayfası |
| GET | /api/feed-partial | Closure | auth | HTML partial (saat başı AJAX) |
| GET | /admin/koleksiyonlar | Livewire: CollectionComponent | admin | Admin liste |
| GET | /admin/koleksiyonlar/manage/{id?} | Livewire: CollectionManageComponent | admin | Admin form (5 tab) |