Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 097c6719a1 |
@@ -1,85 +0,0 @@
|
||||
---
|
||||
description: Google yapılandırılmış veri (JSON-LD) standartları
|
||||
globs: app/Support/*StructuredData*.php, app/Http/Controllers/**/*.php, resources/views/**/*.blade.php
|
||||
---
|
||||
# Yapılandırılmış Veri (Structured Data) Kuralları
|
||||
|
||||
Google [Yapılandırılmış Veri Genel Yönergeleri](https://developers.google.com/search/docs/appearance/structured-data/sd-policies?hl=tr) ve [Giriş](https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data?hl=tr) dokümanlarına uy.
|
||||
|
||||
## Temel İlkeler
|
||||
|
||||
1. **JSON-LD kullan** — Google'ın önerdiği format; `<head>` içinde `<script type="application/ld+json">` ile sunulur.
|
||||
2. **Sayfa içeriğiyle eşleş** — İşaretlenen veri kullanıcının gördüğü içerikle birebir uyumlu olmalı; görünmeyen veya yanlış bilgi ekleme.
|
||||
3. **Eksiksiz ve doğru özellikler** — Zorunlu alanları doldur; eksik/hatalı önerilen alanları doldurmaya çalışmak yerine az ama doğru veri tercih et.
|
||||
4. **schema.org sözlüğü** — `@context: https://schema.org` kullan; Google Arama özellikleri için [Search Central özellik rehberlerini](https://developers.google.com/search/docs/appearance/structured-data/search-gallery?hl=tr) referans al.
|
||||
|
||||
## Proje Mimarisi
|
||||
|
||||
```
|
||||
app/Support/
|
||||
├── StructuredData.php # Ortak yardımcılar (WebSite, Organization, WebPage, BreadcrumbList)
|
||||
├── PageStructuredData.php # CMS sayfaları
|
||||
├── BlogStructuredData.php # Blog listesi ve detay
|
||||
└── MusicProductionStructuredData.php
|
||||
```
|
||||
|
||||
### Controller → View akışı
|
||||
|
||||
```php
|
||||
// Controller
|
||||
'structuredData' => BlogStructuredData::forShow($post, url()->current()),
|
||||
|
||||
// Layout (layouts/site.blade.php) otomatik render eder:
|
||||
@if(!empty($structuredData))
|
||||
<x-seo.json-ld :data="$structuredData" />
|
||||
@endif
|
||||
```
|
||||
|
||||
**Asla** Blade içinde ham JSON string interpolasyonu kullanma — `json_encode` ile `<x-seo.json-ld>` bileşenini kullan.
|
||||
|
||||
## Sayfa Türü → Schema Eşlemesi
|
||||
|
||||
| Sayfa türü | Schema türleri | Sınıf |
|
||||
|---|---|---|
|
||||
| Ana sayfa / CMS sayfaları | WebSite, Organization, WebPage, BreadcrumbList | `PageStructuredData` |
|
||||
| Blog listesi | CollectionPage, ItemList, BlogPosting (özet) | `BlogStructuredData::forIndex` |
|
||||
| Blog detay | BlogPosting, WebPage, BreadcrumbList | `BlogStructuredData::forShow` |
|
||||
| Müzik prodüksiyonları | CollectionPage / MusicAlbum, ItemList | `MusicProductionStructuredData` |
|
||||
| Ürün/hizmet sayfaları | WebPage, Product veya Service | Henüz eklenmedi |
|
||||
| Kariyer / staj | WebPage | Henüz eklenmedi |
|
||||
| İletişim | WebPage, Organization (contactPoint) | Henüz eklenmedi |
|
||||
|
||||
## @graph Deseni
|
||||
|
||||
Birden fazla entity için `@graph` kullan; `@id` ile cross-reference yap:
|
||||
|
||||
```php
|
||||
return self::wrap([
|
||||
self::websiteNode(),
|
||||
self::organizationNode(),
|
||||
self::webPageNode($pageUrl, $name, $description),
|
||||
self::breadcrumbNode($pageUrl, $items),
|
||||
// sayfa-özel entity (BlogPosting, MusicAlbum, vb.)
|
||||
]);
|
||||
```
|
||||
|
||||
## Yeni Sayfa Eklerken
|
||||
|
||||
1. `app/Support/` altında `*StructuredData.php` sınıfı oluştur veya mevcut sınıfı genişlet.
|
||||
2. `StructuredData` base sınıfındaki ortak node'ları kullan (DRY).
|
||||
3. Controller'da `structuredData` key'ini view'a geçir.
|
||||
4. Görünür içerikle eşleştiğini doğrula (başlık, açıklama, görsel, tarih).
|
||||
5. [Zengin Sonuçlar Testi](https://search.google.com/test/rich-results) ile doğrula.
|
||||
|
||||
## Yasaklar
|
||||
|
||||
- ❌ Boş veya içeriksiz sayfalara yalnızca schema eklemek
|
||||
- ❌ Blade'de `"headline": "{{ $title }}"` gibi kaçışsız JSON
|
||||
- ❌ `@push('scripts')` ile body sonuna JSON-LD koymak (head'de olmalı)
|
||||
- ❌ data-vocabulary.org işaretlemesi
|
||||
- ❌ Kullanıcıya görünmeyen rating/review verisi uydurmak
|
||||
|
||||
## Doğrulama
|
||||
|
||||
- Geliştirme: [Zengin Sonuçlar Testi](https://search.google.com/test/rich-results)
|
||||
- Prod: Search Console → Zengin sonuçlar raporları
|
||||
@@ -63,30 +63,3 @@ AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# Spotify Web API (Müzik Prodüksiyonları senkronizasyonu)
|
||||
# NOT: Developer Dashboard'da uygulamayı oluşturan Spotify hesabında Premium abonelik gerekir.
|
||||
SPOTIFY_CLIENT_ID=
|
||||
SPOTIFY_CLIENT_SECRET=
|
||||
SPOTIFY_ARTIST_ID=
|
||||
SPOTIFY_MARKET=TR
|
||||
|
||||
# YouTube Data API v3 (Müzik Prodüksiyonları / Art Track senkronizasyonu)
|
||||
# Ana kanal (@handle) — Videolar sekmesi
|
||||
YOUTUBE_CHANNEL_ID=
|
||||
# Topic kanalı — Yayınlananlar sekmesi (ZORUNLU önerilir, otomatik arama güvenilir değil)
|
||||
YOUTUBE_TOPIC_CHANNEL_ID=UCEGzDgiExoGrwWEnpIdOGRA
|
||||
# Sunucu/cron senkronizasyonu için KISITLAMASIZ veya IP kısıtlı ayrı bir anahtar kullanın.
|
||||
YOUTUBE_API_KEY=
|
||||
|
||||
# LinkedIn API (Şirket Sayfası Paylaşım Entegrasyonu)
|
||||
LINKEDIN_CLIENT_ID=
|
||||
LINKEDIN_CLIENT_SECRET=
|
||||
LINKEDIN_ORGANIZATION_ID=35611757
|
||||
LINKEDIN_REDIRECT_URI=https://truncgil.com/admin/linkedin/callback
|
||||
# İsteğe bağlı: playlist ID doğrudan (Topic uploads: UU + topic channel ID'den sonraki kısım)
|
||||
YOUTUBE_PLAYLIST_ID=
|
||||
# İsteğe bağlı: yalnızca DistroKid yayınları için açıklama filtresi
|
||||
# YOUTUBE_DISTRIBUTOR_FILTER=Provided to YouTube by DistroKid
|
||||
# Sadece referrer kısıtlı web anahtarı kullanıyorsanız:
|
||||
# YOUTUBE_API_REFERER=https://truncgil.com
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Blog;
|
||||
use App\Models\Setting;
|
||||
use App\Services\LinkedInService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PublishScheduledLinkedInPosts extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'linkedin:publish-scheduled';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Zamanlanan ve yayımlanan blog/ürün yazılarını LinkedIn Şirket Sayfasında paylaşır.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(LinkedInService $linkedInService)
|
||||
{
|
||||
if (!$linkedInService->isAuthorized()) {
|
||||
$this->error('LinkedIn yetkilendirmesi yok veya süresi dolmuş.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info('LinkedIn zamanlanmış gönderi kontrolü başlatılıyor...');
|
||||
|
||||
// Fetch recent published blogs that haven't been shared yet or scheduled blogs
|
||||
$blogs = Blog::where('status', 'published')
|
||||
->where('published_at', '<=', now())
|
||||
->orderBy('published_at', 'desc')
|
||||
->take(5)
|
||||
->get();
|
||||
|
||||
$count = 0;
|
||||
foreach ($blogs as $blog) {
|
||||
$this->info("İşleniyor: {$blog->title}");
|
||||
// Can be extended with a flag like linkedin_shared_at
|
||||
}
|
||||
|
||||
$this->info("Zamanlanmış kontrol tamamlandı.");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ResetUserPassword extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'user:reset-password {email? : Kullanıcının e-posta adresi} {password? : Yeni şifre (Belirtilmezse etkileşimli olarak istenir veya otomatik oluşturulur)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Belirtilen kullanıcının şifresini günceller';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
// E-posta adresini al veya sor
|
||||
$email = $this->argument('email') ?: $this->ask('Kullanıcının e-posta adresini giriniz:');
|
||||
|
||||
if (empty($email)) {
|
||||
$this->error('E-posta adresi boş olamaz!');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// Email formatını kontrol et
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$this->error('Geçersiz e-posta formatı!');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// Kullanıcıyı bul
|
||||
$user = User::where('email', $email)->first();
|
||||
|
||||
if (!$user) {
|
||||
$this->error("E-posta adresi '{$email}' ile kayıtlı kullanıcı bulunamadı!");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// Şifreyi al veya sor ya da rastgele oluştur
|
||||
$password = $this->argument('password');
|
||||
|
||||
if ($password === null) {
|
||||
if ($this->confirm('Yeni şifreyi otomatik rastgele mi üretelim (6 haneli rakamsal)?', true)) {
|
||||
$password = (string) random_int(100000, 999999);
|
||||
} else {
|
||||
$password = $this->secret('Yeni şifreyi giriniz:');
|
||||
if (empty($password)) {
|
||||
$this->error('Şifre boş olamaz!');
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Şifreyi güncelle ve kaydet
|
||||
$user->password = Hash::make($password);
|
||||
$user->save();
|
||||
|
||||
$this->info("✓ Kullanıcı '{$user->name}' ({$user->email}) şifresi başarıyla güncellendi!");
|
||||
$this->info("Yeni Şifre: {$password}");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\MusicProduction;
|
||||
use App\Services\SpotifyService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SyncSpotifyMusicProductions extends Command
|
||||
{
|
||||
protected $signature = 'spotify:sync-music-productions
|
||||
{--artist= : Override Spotify artist ID}
|
||||
{--dry-run : Show changes without writing to database}';
|
||||
|
||||
protected $description = 'Spotify sanatçı hesabından albüm ve single verilerini müzik prodüksiyonlarına senkronize eder';
|
||||
|
||||
public function handle(SpotifyService $spotifyService): int
|
||||
{
|
||||
if ($this->option('artist')) {
|
||||
$spotifyService = new SpotifyService(
|
||||
artistId: $this->option('artist'),
|
||||
);
|
||||
}
|
||||
|
||||
if (! $spotifyService->isConfigured()) {
|
||||
$this->error(__('music_productions.spotify_not_configured'));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->info(__('music_productions.spotify_sync_started'));
|
||||
|
||||
try {
|
||||
$albumSummaries = $spotifyService->getArtistAlbums();
|
||||
} catch (\Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (empty($albumSummaries)) {
|
||||
$this->warn(__('music_productions.spotify_no_albums'));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
foreach ($albumSummaries as $index => $albumSummary) {
|
||||
$albumId = $albumSummary['id'] ?? null;
|
||||
|
||||
if (blank($albumId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$album = $spotifyService->getAlbum($albumId) ?? $albumSummary;
|
||||
$title = $album['name'] ?? __('music_productions.spotify_untitled_album');
|
||||
$artistName = collect($album['artists'] ?? [])->pluck('name')->first();
|
||||
$releaseDate = $this->parseReleaseDate($album['release_date'] ?? null, $album['release_date_precision'] ?? 'day');
|
||||
$spotifyUrl = $album['external_urls']['spotify'] ?? null;
|
||||
$coverUrl = collect($album['images'] ?? [])->first()['url'] ?? null;
|
||||
$albumType = $album['album_type'] ?? 'album';
|
||||
|
||||
$production = $this->findMatchingProduction($albumId, $title);
|
||||
|
||||
$action = $production ? 'update' : 'create';
|
||||
$this->line(sprintf(
|
||||
'[%d/%d] %s: %s (%s)',
|
||||
$index + 1,
|
||||
count($albumSummaries),
|
||||
$action === 'create' ? 'Yeni' : 'Güncelle',
|
||||
$title,
|
||||
$releaseDate?->format('Y-m-d') ?? '-'
|
||||
));
|
||||
|
||||
if ($dryRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
'spotify_album_id' => $albumId,
|
||||
'spotify_url' => $spotifyUrl,
|
||||
'spotify_cover_url' => $coverUrl,
|
||||
'spotify_type' => $albumType,
|
||||
'spotify_synced_at' => now(),
|
||||
];
|
||||
|
||||
if (! $production) {
|
||||
$attributes['title'] = $title;
|
||||
$attributes['slug'] = SpotifyService::uniqueSlug($title);
|
||||
$attributes['client_name'] = $artistName;
|
||||
$attributes['production_date'] = $releaseDate;
|
||||
$attributes['content'] = $spotifyService->buildDefaultContent($album);
|
||||
$attributes['is_active'] = true;
|
||||
$attributes['sort_order'] = 0;
|
||||
|
||||
$coverPath = $spotifyService->downloadCoverImage($coverUrl, $albumId);
|
||||
if ($coverPath) {
|
||||
$attributes['cover_image'] = $coverPath;
|
||||
}
|
||||
|
||||
MusicProduction::create($attributes);
|
||||
$created++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (blank($production->title)) {
|
||||
$attributes['title'] = $title;
|
||||
}
|
||||
|
||||
if (blank($production->client_name) && filled($artistName)) {
|
||||
$attributes['client_name'] = $artistName;
|
||||
}
|
||||
|
||||
if (blank($production->production_date) && $releaseDate) {
|
||||
$attributes['production_date'] = $releaseDate;
|
||||
}
|
||||
|
||||
if (blank($production->content)) {
|
||||
$attributes['content'] = $spotifyService->buildDefaultContent($album);
|
||||
}
|
||||
|
||||
if (blank($production->cover_image) && filled($coverUrl)) {
|
||||
$coverPath = $spotifyService->downloadCoverImage($coverUrl, $albumId);
|
||||
if ($coverPath) {
|
||||
$attributes['cover_image'] = $coverPath;
|
||||
}
|
||||
}
|
||||
|
||||
$production->update($attributes);
|
||||
$updated++;
|
||||
|
||||
usleep(150000);
|
||||
}
|
||||
|
||||
$message = __('music_productions.spotify_sync_completed', [
|
||||
'created' => $created,
|
||||
'updated' => $updated,
|
||||
'total' => count($albumSummaries),
|
||||
]);
|
||||
|
||||
$this->info($message);
|
||||
Log::info('Spotify music productions sync completed', [
|
||||
'created' => $created,
|
||||
'updated' => $updated,
|
||||
'total' => count($albumSummaries),
|
||||
'dry_run' => $dryRun,
|
||||
]);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
protected function findMatchingProduction(string $albumId, string $title): ?MusicProduction
|
||||
{
|
||||
$bySpotifyId = MusicProduction::withTrashed()
|
||||
->where('spotify_album_id', $albumId)
|
||||
->first();
|
||||
|
||||
if ($bySpotifyId) {
|
||||
return $bySpotifyId->trashed() ? null : $bySpotifyId;
|
||||
}
|
||||
|
||||
$slug = Str::slug($title);
|
||||
|
||||
$bySlug = MusicProduction::query()
|
||||
->where('slug', $slug)
|
||||
->first();
|
||||
|
||||
if ($bySlug) {
|
||||
return $bySlug;
|
||||
}
|
||||
|
||||
return MusicProduction::query()
|
||||
->whereRaw('LOWER(title) = ?', [Str::lower($title)], 'and')
|
||||
->first();
|
||||
}
|
||||
|
||||
protected function parseReleaseDate(?string $date, string $precision = 'day'): ?Carbon
|
||||
{
|
||||
if (blank($date)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match ($precision) {
|
||||
'year' => Carbon::createFromFormat('Y', $date)->startOfYear(),
|
||||
'month' => Carbon::createFromFormat('Y-m', $date)->startOfMonth(),
|
||||
default => Carbon::parse($date),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\MusicProduction;
|
||||
use App\Services\YouTubeService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SyncYoutubeReleases extends Command
|
||||
{
|
||||
protected $signature = 'youtube:sync-releases
|
||||
{--channel= : Override YouTube main channel ID}
|
||||
{--topic= : Override YouTube Topic channel ID (Yayınlananlar / art track)}
|
||||
{--playlist= : Override Releases playlist ID}
|
||||
{--dry-run : Show changes without writing to database}';
|
||||
|
||||
protected $description = 'YouTube kanalının Releases (Yayınlar) listesindeki art track kayıtlarını müzik prodüksiyonlarına senkronize eder';
|
||||
|
||||
public function handle(YouTubeService $youTubeService): int
|
||||
{
|
||||
if ($this->option('channel') || $this->option('topic') || $this->option('playlist')) {
|
||||
$youTubeService = new YouTubeService(
|
||||
channelId: $this->option('channel') ?? config('services.youtube.channel_id'),
|
||||
topicChannelId: $this->option('topic') ?? config('services.youtube.topic_channel_id'),
|
||||
playlistId: $this->option('playlist') ?? config('services.youtube.playlist_id'),
|
||||
);
|
||||
}
|
||||
|
||||
if (! $youTubeService->isConfigured()) {
|
||||
$this->error(__('music_productions.youtube_not_configured'));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->info(__('music_productions.youtube_sync_started'));
|
||||
$this->line(__('music_productions.youtube_sync_source', [
|
||||
'source' => $youTubeService->getReleasesSourceLabel(),
|
||||
]));
|
||||
$this->line('Playlist ID: ' . $youTubeService->resolveReleasesPlaylistId());
|
||||
|
||||
try {
|
||||
$releases = $youTubeService->getReleases();
|
||||
} catch (\Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (empty($releases)) {
|
||||
$this->warn(__('music_productions.youtube_no_releases'));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
foreach ($releases as $index => $release) {
|
||||
$videoId = $release['video_id'] ?? null;
|
||||
|
||||
if (blank($videoId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$title = $release['title'] ?? __('music_productions.youtube_untitled_release');
|
||||
$releaseDate = filled($release['release_date'] ?? null)
|
||||
? Carbon::parse($release['release_date'])
|
||||
: null;
|
||||
$youtubeUrl = $release['youtube_url'] ?? null;
|
||||
$coverUrl = $release['cover_url'] ?? null;
|
||||
$description = $release['description'] ?? '';
|
||||
|
||||
$production = $this->findMatchingProduction($videoId, $title);
|
||||
|
||||
$action = $production ? 'update' : 'create';
|
||||
$this->line(sprintf(
|
||||
'[%d/%d] %s: %s (%s)',
|
||||
$index + 1,
|
||||
count($releases),
|
||||
$action === 'create' ? 'Yeni' : 'Güncelle',
|
||||
$title,
|
||||
$releaseDate?->format('Y-m-d') ?? '-'
|
||||
));
|
||||
|
||||
if ($dryRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
'youtube_video_id' => $videoId,
|
||||
'youtube_url' => $youtubeUrl,
|
||||
'youtube_cover_url' => $coverUrl,
|
||||
'youtube_description' => $description,
|
||||
'youtube_synced_at' => now(),
|
||||
];
|
||||
|
||||
if (! $production) {
|
||||
$attributes['title'] = $title;
|
||||
$attributes['slug'] = YouTubeService::uniqueSlug($title);
|
||||
$attributes['production_date'] = $releaseDate;
|
||||
$attributes['content'] = $youTubeService->buildDefaultContent($release);
|
||||
$attributes['is_active'] = true;
|
||||
$attributes['sort_order'] = 0;
|
||||
|
||||
$coverPath = $youTubeService->downloadCoverImage($coverUrl, $videoId);
|
||||
if ($coverPath) {
|
||||
$attributes['cover_image'] = $coverPath;
|
||||
}
|
||||
|
||||
MusicProduction::create($attributes);
|
||||
$created++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (blank($production->title)) {
|
||||
$attributes['title'] = $title;
|
||||
}
|
||||
|
||||
if ($releaseDate) {
|
||||
$attributes['production_date'] = $releaseDate;
|
||||
}
|
||||
|
||||
if (blank($production->content)) {
|
||||
$attributes['content'] = $youTubeService->buildDefaultContent($release);
|
||||
}
|
||||
|
||||
if (blank($production->cover_image) && filled($coverUrl)) {
|
||||
$coverPath = $youTubeService->downloadCoverImage($coverUrl, $videoId);
|
||||
if ($coverPath) {
|
||||
$attributes['cover_image'] = $coverPath;
|
||||
}
|
||||
}
|
||||
|
||||
$production->update($attributes);
|
||||
$updated++;
|
||||
|
||||
usleep(150000);
|
||||
}
|
||||
|
||||
$message = __('music_productions.youtube_sync_completed', [
|
||||
'created' => $created,
|
||||
'updated' => $updated,
|
||||
'total' => count($releases),
|
||||
]);
|
||||
|
||||
$this->info($message);
|
||||
Log::info('YouTube releases sync completed', [
|
||||
'created' => $created,
|
||||
'updated' => $updated,
|
||||
'total' => count($releases),
|
||||
'dry_run' => $dryRun,
|
||||
]);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
protected function findMatchingProduction(string $videoId, string $title): ?MusicProduction
|
||||
{
|
||||
$byYoutubeId = MusicProduction::withTrashed()
|
||||
->where('youtube_video_id', $videoId)
|
||||
->first();
|
||||
|
||||
if ($byYoutubeId) {
|
||||
return $byYoutubeId->trashed() ? null : $byYoutubeId;
|
||||
}
|
||||
|
||||
$slug = Str::slug($title);
|
||||
|
||||
$bySlug = MusicProduction::query()
|
||||
->whereNull('youtube_video_id', 'and', false)
|
||||
->where('slug', '=', $slug)
|
||||
->first();
|
||||
|
||||
if ($bySlug) {
|
||||
return $bySlug;
|
||||
}
|
||||
|
||||
return MusicProduction::query()
|
||||
->whereNull('youtube_video_id', 'and', false)
|
||||
->whereRaw('LOWER(title) = ?', [Str::lower($title)], 'and')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Pages;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Services\LinkedInService;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Actions;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class LinkedInSettings extends Page implements HasForms
|
||||
{
|
||||
use InteractsWithForms;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShare;
|
||||
|
||||
protected string $view = 'filament.admin.pages.linked-in-settings';
|
||||
|
||||
protected static ?int $navigationSort = 90;
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'Ayarlar';
|
||||
|
||||
public ?array $data = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->form->fill([
|
||||
'autoPublishBlogs' => (bool) Setting::get('linkedin_auto_publish_blogs', '1'),
|
||||
'autoPublishProducts' => (bool) Setting::get('linkedin_auto_publish_products', '1'),
|
||||
'testTitle' => 'Truncgil Technology Paylaşım Testi',
|
||||
'testText' => 'Truncgil Technology web sitemiz üzerinden LinkedIn entegrasyonumuz başarıyla tamamlanmıştır.',
|
||||
'testUrl' => 'https://truncgil.com',
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return 'LinkedIn Entegrasyonu';
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'LinkedIn Entegrasyonu & Otomasyon';
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
$linkedInService = app(LinkedInService::class);
|
||||
$status = $linkedInService->getTokenExpirationStatus();
|
||||
|
||||
$statusBadgeHtml = $status['is_valid']
|
||||
? '<span class="inline-flex items-center gap-x-1.5 rounded-md bg-emerald-500/10 px-3 py-1.5 text-sm font-semibold text-emerald-600 dark:text-emerald-400 ring-1 ring-inset ring-emerald-500/20">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
Bağlı ve Aktif
|
||||
</span>'
|
||||
: '<span class="inline-flex items-center gap-x-1.5 rounded-md bg-rose-500/10 px-3 py-1.5 text-sm font-semibold text-rose-600 dark:text-rose-400 ring-1 ring-inset ring-rose-500/20">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
Bağlantı Yok / Süresi Dolmuş
|
||||
</span>';
|
||||
|
||||
$statusDetailsHtml = '
|
||||
<div class="mt-3 space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<div><strong>Açıklama:</strong> ' . e($status['message']) . '</div>
|
||||
<div><strong>Client ID:</strong> <code class="rounded bg-gray-100 dark:bg-gray-800 px-2 py-0.5 text-xs font-mono">' . e(config('linkedin.client_id')) . '</code></div>
|
||||
<div><strong>Organization ID:</strong> <code class="rounded bg-gray-100 dark:bg-gray-800 px-2 py-0.5 text-xs font-mono">' . e(config('linkedin.organization_id')) . '</code></div>
|
||||
<div><strong>Redirect URI:</strong> <code class="rounded bg-gray-100 dark:bg-gray-800 px-2 py-0.5 text-xs font-mono">' . e(config('linkedin.redirect_uri')) . '</code></div>
|
||||
</div>';
|
||||
|
||||
return $schema
|
||||
->components([
|
||||
Grid::make(['default' => 1, 'md' => 2])
|
||||
->schema([
|
||||
Section::make('LinkedIn Sayfa Bağlantı Durumu')
|
||||
->description('Truncgil Technology LinkedIn Şirket Sayfası (ID: ' . config('linkedin.organization_id') . ') Entegrasyon Bilgileri')
|
||||
->icon('heroicon-o-link')
|
||||
->columnSpan(1)
|
||||
->schema([
|
||||
Placeholder::make('connection_status')
|
||||
->label('Erişim Durumu')
|
||||
->content(new HtmlString($statusBadgeHtml . $statusDetailsHtml)),
|
||||
|
||||
Actions::make([
|
||||
Action::make('connect')
|
||||
->label($status['is_valid'] ? 'Yeniden Yetkilendir' : 'LinkedIn ile Bağlan')
|
||||
->icon('heroicon-o-arrow-right-end-on-rectangle')
|
||||
->color($status['is_valid'] ? 'gray' : 'primary')
|
||||
->url(route('admin.linkedin.connect'))
|
||||
->openUrlInNewTab(false),
|
||||
]),
|
||||
]),
|
||||
|
||||
Section::make('Otomatik Yayınlama Kuralları')
|
||||
->description('Hangi içeriklerin otomatik olarak LinkedIn Şirket Sayfasında paylaşılacağını belirleyin.')
|
||||
->icon('heroicon-o-cog-6-tooth')
|
||||
->columnSpan(1)
|
||||
->schema([
|
||||
Toggle::make('autoPublishBlogs')
|
||||
->label('Blog Yazıları')
|
||||
->helperText('Yeni bir blog yazısı yayımlandığında otomatik LinkedIn gönderisi oluştur.')
|
||||
->default(true),
|
||||
|
||||
Toggle::make('autoPublishProducts')
|
||||
->label('Ürün ve Hizmetler')
|
||||
->helperText('Yeni bir ürün/hizmet yayımlandığında LinkedIn sayfasında duyur.')
|
||||
->default(true),
|
||||
|
||||
Actions::make([
|
||||
Action::make('saveAutoPublishSettings')
|
||||
->label('Kuralları Kaydet')
|
||||
->icon('heroicon-o-check')
|
||||
->color('primary')
|
||||
->action('saveSettings'),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
|
||||
Section::make('LinkedIn Canlı Test Gönderisi Gönder')
|
||||
->description('Entegrasyonu doğrulamak için hemen şirket sayfanıza canlı bir test gönderisi atabilirsiniz.')
|
||||
->icon('heroicon-o-paper-airplane')
|
||||
->schema([
|
||||
TextInput::make('testTitle')
|
||||
->label('Gönderi Başlığı')
|
||||
->required()
|
||||
->maxLength(200),
|
||||
|
||||
Textarea::make('testText')
|
||||
->label('Gönderi İçeriği (Açıklama)')
|
||||
->required()
|
||||
->rows(3),
|
||||
|
||||
TextInput::make('testUrl')
|
||||
->label('Hedef Bağlantı URL (Opsiyonel)')
|
||||
->url(),
|
||||
|
||||
Actions::make([
|
||||
Action::make('sendTest')
|
||||
->label('Test Gönderisini LinkedIn\'de Paylaş')
|
||||
->icon('heroicon-o-paper-airplane')
|
||||
->color('success')
|
||||
->action('sendTestPost'),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->statePath('data');
|
||||
}
|
||||
|
||||
public function saveSettings(): void
|
||||
{
|
||||
$state = $this->form->getState();
|
||||
|
||||
Setting::updateOrCreate(
|
||||
['key' => 'linkedin_auto_publish_blogs'],
|
||||
['value' => !empty($state['autoPublishBlogs']) ? '1' : '0', 'type' => 'boolean', 'group' => 'social_media', 'label' => 'Auto Publish Blogs to LinkedIn']
|
||||
);
|
||||
|
||||
Setting::updateOrCreate(
|
||||
['key' => 'linkedin_auto_publish_products'],
|
||||
['value' => !empty($state['autoPublishProducts']) ? '1' : '0', 'type' => 'boolean', 'group' => 'social_media', 'label' => 'Auto Publish Products to LinkedIn']
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Ayarlar Kaydedildi')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
public function sendTestPost(): void
|
||||
{
|
||||
$state = $this->form->getState();
|
||||
$linkedInService = app(LinkedInService::class);
|
||||
|
||||
if (!$linkedInService->isAuthorized()) {
|
||||
Notification::make()
|
||||
->title('Yetkilendirme Gerekli')
|
||||
->body('Lütfen önce LinkedIn hesabınızı bağlayın.')
|
||||
->warning()
|
||||
->send();
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $linkedInService->sharePost(
|
||||
$state['testTitle'] ?? '',
|
||||
$state['testText'] ?? '',
|
||||
$state['testUrl'] ?? null
|
||||
);
|
||||
|
||||
if ($result['success']) {
|
||||
Notification::make()
|
||||
->title('Başarılı!')
|
||||
->body($result['message'])
|
||||
->success()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title('Paylaşım Başarısız')
|
||||
->body($result['message'])
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\Awards;
|
||||
|
||||
use App\Filament\Admin\Resources\Awards\Pages\CreateAward;
|
||||
use App\Filament\Admin\Resources\Awards\Pages\EditAward;
|
||||
use App\Filament\Admin\Resources\Awards\Pages\ListAwards;
|
||||
use App\Filament\Admin\Resources\Awards\Schemas\AwardForm;
|
||||
use App\Filament\Admin\Resources\Awards\Tables\AwardsTable;
|
||||
use App\Models\Award;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
|
||||
class AwardResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Award::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-trophy';
|
||||
|
||||
protected static ?int $navigationSort = 46;
|
||||
|
||||
public static function getNavigationGroup(): ?string
|
||||
{
|
||||
return __('awards.navigation_group');
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('awards.navigation_label');
|
||||
}
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('awards.model_label');
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return __('awards.plural_model_label');
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return AwardForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return AwardsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListAwards::route('/'),
|
||||
'create' => CreateAward::route('/create'),
|
||||
'edit' => EditAward::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getRecordRouteBindingEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getRecordRouteBindingEloquentQuery()
|
||||
->withoutGlobalScopes([
|
||||
SoftDeletingScope::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\Awards\Pages;
|
||||
|
||||
use App\Filament\Admin\Resources\Awards\AwardResource;
|
||||
use App\Filament\Admin\Resources\Components\TranslationTabs;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateAward extends CreateRecord
|
||||
{
|
||||
protected static string $resource = AwardResource::class;
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return __('awards.create') ?? 'Yeni Ödül Ekle';
|
||||
}
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return $this->getResource()::getUrl('index');
|
||||
}
|
||||
|
||||
protected function getCreatedNotificationTitle(): ?string
|
||||
{
|
||||
return __('awards.created_successfully') ?? 'Ödül başarıyla eklendi.';
|
||||
}
|
||||
|
||||
protected function afterCreate(): void
|
||||
{
|
||||
TranslationTabs::saveTranslations($this->record, $this->form->getState());
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\Awards\Pages;
|
||||
|
||||
use App\Filament\Admin\Resources\Awards\AwardResource;
|
||||
use App\Filament\Admin\Resources\Components\TranslationTabs;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\ForceDeleteAction;
|
||||
use Filament\Actions\RestoreAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditAward extends EditRecord
|
||||
{
|
||||
protected static string $resource = AwardResource::class;
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return __('awards.edit') ?? 'Ödülü Düzenle';
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make()
|
||||
->label(__('awards.delete') ?? 'Sil'),
|
||||
ForceDeleteAction::make()
|
||||
->label(__('awards.force_delete') ?? 'Kalıcı Olarak Sil'),
|
||||
RestoreAction::make()
|
||||
->label(__('awards.restore') ?? 'Geri Yükle'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return $this->getResource()::getUrl('index');
|
||||
}
|
||||
|
||||
protected function getSavedNotificationTitle(): ?string
|
||||
{
|
||||
return __('awards.updated_successfully') ?? 'Ödül başarıyla güncellendi.';
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeFill(array $data): array
|
||||
{
|
||||
return array_merge($data, TranslationTabs::fillFromRecord($this->record));
|
||||
}
|
||||
|
||||
protected function afterSave(): void
|
||||
{
|
||||
TranslationTabs::saveTranslations($this->record, $this->form->getState());
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\Awards\Pages;
|
||||
|
||||
use App\Filament\Admin\Resources\Awards\AwardResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListAwards extends ListRecords
|
||||
{
|
||||
protected static string $resource = AwardResource::class;
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return __('awards.title') ?? 'Ödüllerimiz';
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make()
|
||||
->label(__('awards.create') ?? 'Yeni Ödül Ekle'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\Awards\Schemas;
|
||||
|
||||
use App\Filament\Admin\Resources\Components\TranslationTabs;
|
||||
use Filament\Forms\Components\Checkbox;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class AwardForm
|
||||
{
|
||||
public static function categoryOptions(): array
|
||||
{
|
||||
return [
|
||||
'hackathon' => __('awards.category_hackathon') ?? 'Hackathon',
|
||||
'export' => __('awards.category_export') ?? 'İhracat',
|
||||
'innovation' => __('awards.category_innovation') ?? 'İnovasyon',
|
||||
'design' => __('awards.category_design') ?? 'Tasarım',
|
||||
'general' => __('awards.category_general') ?? 'Genel',
|
||||
];
|
||||
}
|
||||
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->columns(3)
|
||||
->schema([
|
||||
Section::make(__('awards.content_section') ?? 'Ödül İçeriği')
|
||||
->schema([
|
||||
TextInput::make('title')
|
||||
->label(__('awards.title_field') ?? 'Ödül Başlığı (Varsayılan)')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
|
||||
TextInput::make('issuer')
|
||||
->label(__('awards.issuer_field') ?? 'Ödülü Veren Kurum (Varsayılan)')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
|
||||
Textarea::make('description')
|
||||
->label(__('awards.description_field') ?? 'Açıklama (Varsayılan)')
|
||||
->required()
|
||||
->rows(5)
|
||||
->columnSpanFull(),
|
||||
|
||||
TranslationTabs::make([
|
||||
'title' => [
|
||||
'type' => 'text',
|
||||
'label' => __('awards.title_field') ?? 'Ödül Başlığı',
|
||||
'required' => false,
|
||||
'maxLength' => 255,
|
||||
],
|
||||
'issuer' => [
|
||||
'type' => 'text',
|
||||
'label' => __('awards.issuer_field') ?? 'Ödülü Veren Kurum',
|
||||
'required' => false,
|
||||
'maxLength' => 255,
|
||||
],
|
||||
'description' => [
|
||||
'type' => 'textarea',
|
||||
'label' => __('awards.description_field') ?? 'Açıklama',
|
||||
'required' => false,
|
||||
'rows' => 5,
|
||||
],
|
||||
]),
|
||||
])
|
||||
->columnSpan(2),
|
||||
|
||||
Section::make(__('awards.settings_section') ?? 'Ödül Ayarları')
|
||||
->schema([
|
||||
FileUpload::make('image')
|
||||
->label(__('awards.image_field') ?? 'Ödül Görseli / Logo')
|
||||
->image()
|
||||
->disk('public')
|
||||
->directory('awards')
|
||||
->required(),
|
||||
|
||||
DatePicker::make('award_date')
|
||||
->label(__('awards.date_field') ?? 'Ödül Tarihi')
|
||||
->required(),
|
||||
|
||||
Select::make('category')
|
||||
->label(__('awards.category_field') ?? 'Kategori')
|
||||
->options(self::categoryOptions())
|
||||
->default('general')
|
||||
->required()
|
||||
->native(false),
|
||||
|
||||
TextInput::make('external_link')
|
||||
->label(__('awards.link_field') ?? 'Doğrulama / Haber Linki')
|
||||
->url()
|
||||
->maxLength(255),
|
||||
|
||||
TextInput::make('sort_order')
|
||||
->label(__('awards.sort_order_field') ?? 'Sıralama')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->minValue(0),
|
||||
|
||||
Toggle::make('is_featured')
|
||||
->label(__('awards.is_featured_field') ?? 'Öne Çıkarılan Ödül')
|
||||
->default(false),
|
||||
|
||||
Toggle::make('is_active')
|
||||
->label(__('awards.is_active_field') ?? 'Aktif / Görünür')
|
||||
->default(true),
|
||||
])
|
||||
->columnSpan(1),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\Awards\Tables;
|
||||
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\ForceDeleteBulkAction;
|
||||
use Filament\Actions\RestoreBulkAction;
|
||||
use Filament\Tables\Columns\ImageColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\ToggleColumn;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Filters\TrashedFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class AwardsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
ImageColumn::make('image')
|
||||
->label(__('awards.table_image') ?? 'Logo')
|
||||
->disk('public')
|
||||
->square()
|
||||
->size(50),
|
||||
|
||||
TextColumn::make('title')
|
||||
->label(__('awards.table_title') ?? 'Ödül Başlığı')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->limit(40),
|
||||
|
||||
TextColumn::make('issuer')
|
||||
->label(__('awards.table_issuer') ?? 'Veren Kurum')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->limit(30),
|
||||
|
||||
TextColumn::make('award_date')
|
||||
->label(__('awards.table_date') ?? 'Ödül Tarihi')
|
||||
->date('d.m.Y')
|
||||
->sortable()
|
||||
->alignCenter(),
|
||||
|
||||
TextColumn::make('category')
|
||||
->label(__('awards.table_category') ?? 'Kategori')
|
||||
->badge()
|
||||
->formatStateUsing(fn (string $state): string => match ($state) {
|
||||
'hackathon' => __('awards.category_hackathon') ?? 'Hackathon',
|
||||
'export' => __('awards.category_export') ?? 'İhracat',
|
||||
'innovation' => __('awards.category_innovation') ?? 'İnovasyon',
|
||||
'design' => __('awards.category_design') ?? 'Tasarım',
|
||||
default => __('awards.category_general') ?? 'Genel',
|
||||
})
|
||||
->sortable()
|
||||
->alignCenter(),
|
||||
|
||||
ToggleColumn::make('is_featured')
|
||||
->label(__('awards.table_is_featured') ?? 'Öne Çıkan')
|
||||
->alignCenter(),
|
||||
|
||||
ToggleColumn::make('is_active')
|
||||
->label(__('awards.table_is_active') ?? 'Aktif')
|
||||
->alignCenter(),
|
||||
|
||||
TextColumn::make('sort_order')
|
||||
->label(__('awards.sort_order_field') ?? 'Sıra')
|
||||
->sortable()
|
||||
->alignCenter(),
|
||||
])
|
||||
->filters([
|
||||
TernaryFilter::make('is_active')
|
||||
->label(__('awards.is_active_field') ?? 'Aktiflik Durumu'),
|
||||
TernaryFilter::make('is_featured')
|
||||
->label(__('awards.is_featured_field') ?? 'Öne Çıkarılma Durumu'),
|
||||
TrashedFilter::make(),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make()
|
||||
->label(__('awards.edit') ?? 'Düzenle'),
|
||||
])
|
||||
->actions([
|
||||
// Individual actions can be put here if necessary
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make()
|
||||
->label(__('awards.delete') ?? 'Sil'),
|
||||
RestoreBulkAction::make()
|
||||
->label(__('awards.restore') ?? 'Geri Yükle'),
|
||||
ForceDeleteBulkAction::make()
|
||||
->label(__('awards.force_delete') ?? 'Kalıcı Olarak Sil'),
|
||||
]),
|
||||
])
|
||||
->defaultSort('sort_order', 'asc')
|
||||
->reorderable('sort_order');
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ use App\Filament\Admin\Resources\Components\TranslationTabs;
|
||||
use Filament\Forms\Components\Checkbox;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\RichEditor;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Forms\Components\Select;
|
||||
@@ -81,17 +80,11 @@ class BlogForm
|
||||
->helperText(__('blog.featured_image_helper'))
|
||||
->columnSpanFull(),
|
||||
|
||||
Placeholder::make('intern_author_info')
|
||||
->label('Stajyer Yazarı')
|
||||
->content(fn ($record) => $record?->careerApplication?->name ?? '-')
|
||||
->visible(fn ($record) => $record?->career_application_id !== null)
|
||||
->columnSpanFull(),
|
||||
|
||||
Select::make('author_id')
|
||||
->label(__('blog.author_field'))
|
||||
->relationship('author', 'name')
|
||||
->nullable()
|
||||
->placeholder('Boş bırakılırsa stajyer yazarı veya Trunçgil')
|
||||
->default(auth()->id())
|
||||
->required()
|
||||
->columnSpanFull(),
|
||||
|
||||
Select::make('category_id')
|
||||
@@ -117,9 +110,7 @@ class BlogForm
|
||||
->label(__('blog.status_field'))
|
||||
->options([
|
||||
'draft' => __('blog.status_draft'),
|
||||
'pending' => __('blog.status_pending'),
|
||||
'published' => __('blog.status_published'),
|
||||
'rejected' => __('blog.status_rejected'),
|
||||
'archived' => __('blog.status_archived'),
|
||||
])
|
||||
->default('draft')
|
||||
@@ -146,20 +137,12 @@ class BlogForm
|
||||
TextInput::make('meta_title')
|
||||
->label(__('blog.meta_title_field'))
|
||||
->maxLength(60)
|
||||
->extraInputAttributes(['maxlength' => 60])
|
||||
->live(debounce: 200)
|
||||
->hint(fn ($state) => mb_strlen((string) $state) . ' / 60')
|
||||
->hintColor(fn ($state) => mb_strlen((string) $state) > 60 ? 'danger' : 'gray')
|
||||
->helperText(__('blog.meta_title_helper')),
|
||||
|
||||
Textarea::make('meta_description')
|
||||
->label(__('blog.meta_description_field'))
|
||||
->rows(3)
|
||||
->maxLength(160)
|
||||
->extraInputAttributes(['maxlength' => 160])
|
||||
->live(debounce: 200)
|
||||
->hint(fn ($state) => mb_strlen((string) $state) . ' / 160')
|
||||
->hintColor(fn ($state) => mb_strlen((string) $state) > 160 ? 'danger' : 'gray')
|
||||
->helperText(__('blog.meta_description_helper'))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
|
||||
@@ -2,14 +2,11 @@
|
||||
|
||||
namespace App\Filament\Admin\Resources\Blogs\Tables;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\ForceDeleteBulkAction;
|
||||
use Filament\Actions\RestoreBulkAction;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Tables\Columns\ImageColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\ToggleColumn;
|
||||
@@ -35,54 +32,29 @@ class BlogsTable
|
||||
->sortable()
|
||||
->limit(50),
|
||||
|
||||
TextColumn::make('slug')
|
||||
->label(__('blog.table_slug'))
|
||||
->searchable()
|
||||
->sortable()
|
||||
->limit(30),
|
||||
|
||||
TextColumn::make('status')
|
||||
->label(__('blog.table_status'))
|
||||
->badge()
|
||||
->color(fn (?string $state): string => match ($state) {
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'draft' => 'gray',
|
||||
'pending' => 'info',
|
||||
'published' => 'success',
|
||||
'rejected' => 'danger',
|
||||
'archived' => 'warning',
|
||||
default => 'gray',
|
||||
})
|
||||
->formatStateUsing(fn (?string $state): string => match ($state) {
|
||||
'draft' => 'Taslak',
|
||||
'pending' => 'Onay Bekliyor',
|
||||
'published' => 'Yayınlandı',
|
||||
'rejected' => 'Revize İstendi',
|
||||
'archived' => 'Arşivlendi',
|
||||
default => $state ?? '-',
|
||||
->formatStateUsing(fn (string $state): string => match ($state) {
|
||||
'draft' => __('blog.status_draft'),
|
||||
'published' => __('blog.status_published'),
|
||||
'archived' => __('blog.status_archived'),
|
||||
}),
|
||||
|
||||
TextColumn::make('careerApplication.name')
|
||||
->label('Stajyer')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->badge()
|
||||
->color('purple')
|
||||
->toggleable(),
|
||||
|
||||
TextColumn::make('intern_category')
|
||||
->label('Staj Konusu')
|
||||
->formatStateUsing(fn (?string $state): string => match ($state) {
|
||||
'experience' => '1. Staj Tecrübesi',
|
||||
'technical_challenge' => '2. Teknik Zorluklar',
|
||||
'product_showcase' => '3. Ürün Tanıtımı',
|
||||
default => $state ?? '-',
|
||||
})
|
||||
->badge()
|
||||
->toggleable(),
|
||||
|
||||
TextColumn::make('author.name')
|
||||
->label(__('blog.table_author'))
|
||||
->formatStateUsing(fn ($state, $record) => $record->author_name)
|
||||
->searchable(query: function ($query, string $search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->whereHas('author', fn ($sub) => $sub->where('name', 'like', "%{$search}%"))
|
||||
->orWhereHas('careerApplication', fn ($sub) => $sub->where('name', 'like', "%{$search}%"));
|
||||
});
|
||||
})
|
||||
->searchable()
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('category.name')
|
||||
@@ -103,79 +75,39 @@ class BlogsTable
|
||||
ToggleColumn::make('is_featured')
|
||||
->label(__('blog.is_featured_field'))
|
||||
->alignCenter(),
|
||||
|
||||
TextColumn::make('created_at')
|
||||
->label(__('blog.table_created_at'))
|
||||
->dateTime('d.m.Y H:i')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
TextColumn::make('updated_at')
|
||||
->label(__('blog.table_updated_at'))
|
||||
->dateTime('d.m.Y H:i')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('status')
|
||||
->label(__('blog.status_field'))
|
||||
->options([
|
||||
'draft' => 'Taslak',
|
||||
'pending' => 'Onay Bekliyor',
|
||||
'published' => 'Yayınlandı',
|
||||
'rejected' => 'Revize İstendi',
|
||||
'archived' => 'Arşivlendi',
|
||||
'draft' => __('blog.status_draft'),
|
||||
'published' => __('blog.status_published'),
|
||||
'archived' => __('blog.status_archived'),
|
||||
]),
|
||||
|
||||
SelectFilter::make('intern_category')
|
||||
->label('Staj Blog Konusu')
|
||||
->options([
|
||||
'experience' => '1. Staj Tecrübesi',
|
||||
'technical_challenge' => '2. Teknik Zorluklar',
|
||||
'product_showcase' => '3. Ürün Tanıtımı',
|
||||
]),
|
||||
|
||||
TernaryFilter::make('is_intern_blog')
|
||||
->label('Sadece Stajyer Yazıları')
|
||||
->queries(
|
||||
true: fn ($query) => $query->whereNotNull('career_application_id'),
|
||||
false: fn ($query) => $query->whereNull('career_application_id'),
|
||||
),
|
||||
|
||||
SelectFilter::make('category_id')
|
||||
->label(__('blog.category_field'))
|
||||
->relationship('category', 'name'),
|
||||
|
||||
TernaryFilter::make('is_featured')
|
||||
->label(__('blog.is_featured_field')),
|
||||
|
||||
TernaryFilter::make('allow_comments')
|
||||
->label(__('blog.allow_comments_field')),
|
||||
])
|
||||
->recordActions([
|
||||
Action::make('approve')
|
||||
->label('Onayla & Yayınla')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('success')
|
||||
->visible(fn ($record) => in_array($record->status, ['pending', 'draft', 'rejected']))
|
||||
->action(function ($record) {
|
||||
$record->status = 'published';
|
||||
$record->published_at = now();
|
||||
$record->admin_feedback = null;
|
||||
$record->save();
|
||||
|
||||
Notification::make()
|
||||
->title('Yazı Onaylandı')
|
||||
->body('Blog yazısı başarıyla yayınlandı.')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
|
||||
Action::make('reject')
|
||||
->label('Revize İstə')
|
||||
->icon('heroicon-o-x-circle')
|
||||
->color('danger')
|
||||
->visible(fn ($record) => in_array($record->status, ['pending', 'published']))
|
||||
->form([
|
||||
Textarea::make('admin_feedback')
|
||||
->label('Revizyon Gerekçesi / Stajyere Not')
|
||||
->required()
|
||||
->placeholder('Örn: Başlığı ve içerikteki kod bloklarını düzenleyiniz.'),
|
||||
])
|
||||
->action(function ($record, array $data) {
|
||||
$record->status = 'rejected';
|
||||
$record->admin_feedback = $data['admin_feedback'];
|
||||
$record->save();
|
||||
|
||||
Notification::make()
|
||||
->title('Revizyon Talebi Gönderildi')
|
||||
->body('Stajyere revizyon bildirimi iletildi.')
|
||||
->warning()
|
||||
->send();
|
||||
}),
|
||||
|
||||
EditAction::make()
|
||||
->label(__('blog.edit')),
|
||||
])
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\CareerApplications;
|
||||
|
||||
use App\Filament\Admin\Resources\CareerApplications\Pages\CreateCareerApplication;
|
||||
use App\Filament\Admin\Resources\CareerApplications\Pages\EditCareerApplication;
|
||||
use App\Filament\Admin\Resources\CareerApplications\Pages\ListCareerApplications;
|
||||
use App\Filament\Admin\Resources\CareerApplications\Schemas\CareerApplicationForm;
|
||||
use App\Filament\Admin\Resources\CareerApplications\Tables\CareerApplicationsTable;
|
||||
use App\Models\CareerApplication;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class CareerApplicationResource extends Resource
|
||||
{
|
||||
protected static ?string $model = CareerApplication::class;
|
||||
|
||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-user-group';
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('career.navigation_label');
|
||||
}
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('career.model_label');
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return __('career.plural_model_label');
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return CareerApplicationForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return CareerApplicationsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListCareerApplications::route('/'),
|
||||
'create' => CreateCareerApplication::route('/create'),
|
||||
'edit' => EditCareerApplication::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\CareerApplications\Pages;
|
||||
|
||||
use App\Filament\Admin\Resources\CareerApplications\CareerApplicationResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateCareerApplication extends CreateRecord
|
||||
{
|
||||
protected static string $resource = CareerApplicationResource::class;
|
||||
}
|
||||
+4
-4
@@ -1,14 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\InternApplications\Pages;
|
||||
namespace App\Filament\Admin\Resources\CareerApplications\Pages;
|
||||
|
||||
use App\Filament\Admin\Resources\InternApplications\InternApplicationResource;
|
||||
use App\Filament\Admin\Resources\CareerApplications\CareerApplicationResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditInternApplication extends EditRecord
|
||||
class EditCareerApplication extends EditRecord
|
||||
{
|
||||
protected static string $resource = InternApplicationResource::class;
|
||||
protected static string $resource = CareerApplicationResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\CareerApplications\Pages;
|
||||
|
||||
use App\Filament\Admin\Resources\CareerApplications\CareerApplicationResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListCareerApplications extends ListRecords
|
||||
{
|
||||
protected static string $resource = CareerApplicationResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\CareerApplications\Schemas;
|
||||
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class CareerApplicationForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('name')
|
||||
->label(__('career.name'))
|
||||
->required()
|
||||
->disabled(),
|
||||
|
||||
TextInput::make('email')
|
||||
->label(__('career.email'))
|
||||
->email()
|
||||
->required()
|
||||
->disabled(),
|
||||
|
||||
TextInput::make('phone')
|
||||
->label(__('career.phone'))
|
||||
->disabled(),
|
||||
|
||||
FileUpload::make('cv_path')
|
||||
->label(__('career.cv'))
|
||||
->disk('public')
|
||||
->directory('cvs')
|
||||
->required()
|
||||
->disabled(),
|
||||
|
||||
Textarea::make('message')
|
||||
->label(__('career.message'))
|
||||
->disabled()
|
||||
->columnSpanFull(),
|
||||
|
||||
Select::make('status')
|
||||
->label(__('career.status'))
|
||||
->options([
|
||||
'pending' => 'Pending',
|
||||
'reviewed' => 'Reviewed',
|
||||
'rejected' => 'Rejected',
|
||||
'accepted' => 'Accepted',
|
||||
])
|
||||
->required(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\CareerApplications\Tables;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CareerApplicationsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label(__('career.name'))
|
||||
->searchable()
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('email')
|
||||
->label(__('career.email'))
|
||||
->searchable()
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('phone')
|
||||
->label(__('career.phone'))
|
||||
->searchable(),
|
||||
|
||||
TextColumn::make('type')
|
||||
->label(__('career.type'))
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'job' => 'success',
|
||||
'internship' => 'warning',
|
||||
default => 'gray',
|
||||
})
|
||||
->formatStateUsing(fn (string $state): string => __("career.{$state}")),
|
||||
|
||||
TextColumn::make('status')
|
||||
->label(__('career.status'))
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'pending' => 'gray',
|
||||
'reviewed' => 'info',
|
||||
'rejected' => 'danger',
|
||||
'accepted' => 'success',
|
||||
default => 'gray',
|
||||
}),
|
||||
|
||||
TextColumn::make('git_knowledge')
|
||||
->label(__('career.git_knowledge'))
|
||||
->badge()
|
||||
->color(fn ($state) => $state ? 'success' : 'danger')
|
||||
->formatStateUsing(fn ($state) => $state ? 'Evet' : 'Hayır'),
|
||||
|
||||
TextColumn::make('ai_usage')
|
||||
->label(__('career.ai_usage'))
|
||||
->badge()
|
||||
->color(fn ($state) => $state ? 'success' : 'danger')
|
||||
->formatStateUsing(fn ($state) => $state ? 'Evet' : 'Hayır'),
|
||||
|
||||
TextColumn::make('created_at')
|
||||
->label(__('career.created_at'))
|
||||
->dateTime('d.m.Y H:i')
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('status')
|
||||
->label(__('career.status'))
|
||||
->options([
|
||||
'pending' => 'Pending',
|
||||
'reviewed' => 'Reviewed',
|
||||
'rejected' => 'Rejected',
|
||||
'accepted' => 'Accepted',
|
||||
]),
|
||||
SelectFilter::make('type')
|
||||
->label(__('career.type'))
|
||||
->options([
|
||||
'job' => __('career.job'),
|
||||
'internship' => __('career.internship'),
|
||||
]),
|
||||
])
|
||||
->actions([
|
||||
Action::make('download_cv')
|
||||
->label(__('career.download_cv'))
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->url(fn ($record) => Storage::disk('public')->url($record->cv_path))
|
||||
->openUrlInNewTab(),
|
||||
Action::make('download_nda')
|
||||
->label(__('career.nda'))
|
||||
->icon('heroicon-o-shield-check')
|
||||
->url(fn ($record) => $record->nda_path ? Storage::disk('public')->url($record->nda_path) : null)
|
||||
->visible(fn ($record) => $record->nda_path !== null)
|
||||
->openUrlInNewTab(),
|
||||
Action::make('download_contract')
|
||||
->label(__('career.contract'))
|
||||
->icon('heroicon-o-document-text')
|
||||
->url(fn ($record) => $record->contract_path ? Storage::disk('public')->url($record->contract_path) : null)
|
||||
->visible(fn ($record) => $record->contract_path !== null)
|
||||
->openUrlInNewTab(),
|
||||
DeleteAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
])
|
||||
->defaultSort('created_at', 'desc');
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\CompanyHistoryItems;
|
||||
|
||||
use App\Filament\Admin\Resources\CompanyHistoryItems\Pages\CreateCompanyHistoryItem;
|
||||
use App\Filament\Admin\Resources\CompanyHistoryItems\Pages\EditCompanyHistoryItem;
|
||||
use App\Filament\Admin\Resources\CompanyHistoryItems\Pages\ListCompanyHistoryItems;
|
||||
use App\Filament\Admin\Resources\CompanyHistoryItems\Schemas\CompanyHistoryItemForm;
|
||||
use App\Filament\Admin\Resources\CompanyHistoryItems\Tables\CompanyHistoryItemsTable;
|
||||
use App\Models\CompanyHistoryItem;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
|
||||
class CompanyHistoryItemResource extends Resource
|
||||
{
|
||||
protected static ?string $model = CompanyHistoryItem::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
|
||||
|
||||
protected static ?int $navigationSort = 45;
|
||||
|
||||
public static function getNavigationGroup(): ?string
|
||||
{
|
||||
return __('company_history.navigation_group');
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('company_history.navigation_label');
|
||||
}
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('company_history.model_label');
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return __('company_history.plural_model_label');
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return CompanyHistoryItemForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return CompanyHistoryItemsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListCompanyHistoryItems::route('/'),
|
||||
'create' => CreateCompanyHistoryItem::route('/create'),
|
||||
'edit' => EditCompanyHistoryItem::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getRecordRouteBindingEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getRecordRouteBindingEloquentQuery()
|
||||
->withoutGlobalScopes([
|
||||
SoftDeletingScope::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\CompanyHistoryItems\Pages;
|
||||
|
||||
use App\Filament\Admin\Resources\CompanyHistoryItems\CompanyHistoryItemResource;
|
||||
use App\Filament\Admin\Resources\Components\TranslationTabs;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateCompanyHistoryItem extends CreateRecord
|
||||
{
|
||||
protected static string $resource = CompanyHistoryItemResource::class;
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return __('company_history.create');
|
||||
}
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return $this->getResource()::getUrl('index');
|
||||
}
|
||||
|
||||
protected function getCreatedNotificationTitle(): ?string
|
||||
{
|
||||
return __('company_history.created_successfully');
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
if (empty($data['position'])) {
|
||||
$data['position'] = null;
|
||||
}
|
||||
|
||||
unset($data['color_custom']);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function afterCreate(): void
|
||||
{
|
||||
TranslationTabs::saveTranslations($this->record, $this->form->getState());
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\CompanyHistoryItems\Pages;
|
||||
|
||||
use App\Filament\Admin\Resources\CompanyHistoryItems\CompanyHistoryItemResource;
|
||||
use App\Filament\Admin\Resources\Components\TranslationTabs;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\ForceDeleteAction;
|
||||
use Filament\Actions\RestoreAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditCompanyHistoryItem extends EditRecord
|
||||
{
|
||||
protected static string $resource = CompanyHistoryItemResource::class;
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return __('company_history.edit');
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make()
|
||||
->label(__('company_history.delete')),
|
||||
ForceDeleteAction::make()
|
||||
->label(__('company_history.force_delete')),
|
||||
RestoreAction::make()
|
||||
->label(__('company_history.restore')),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return $this->getResource()::getUrl('index');
|
||||
}
|
||||
|
||||
protected function getSavedNotificationTitle(): ?string
|
||||
{
|
||||
return __('company_history.updated_successfully');
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeFill(array $data): array
|
||||
{
|
||||
return array_merge($data, TranslationTabs::fillFromRecord($this->record));
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
{
|
||||
if (empty($data['position'])) {
|
||||
$data['position'] = null;
|
||||
}
|
||||
|
||||
unset($data['color_custom']);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function afterSave(): void
|
||||
{
|
||||
TranslationTabs::saveTranslations($this->record, $this->form->getState());
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\CompanyHistoryItems\Pages;
|
||||
|
||||
use App\Filament\Admin\Resources\CompanyHistoryItems\CompanyHistoryItemResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListCompanyHistoryItems extends ListRecords
|
||||
{
|
||||
protected static string $resource = CompanyHistoryItemResource::class;
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return __('company_history.title');
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make()
|
||||
->label(__('company_history.create')),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\CompanyHistoryItems\Schemas;
|
||||
|
||||
use App\Filament\Admin\Resources\Components\TranslationTabs;
|
||||
use Filament\Forms\Components\Checkbox;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class CompanyHistoryItemForm
|
||||
{
|
||||
public static function iconOptions(): array
|
||||
{
|
||||
return [
|
||||
'heroicon-o-building-office-2' => __('company_history.icon_building'),
|
||||
'heroicon-o-academic-cap' => __('company_history.icon_academic'),
|
||||
'heroicon-o-globe-alt' => __('company_history.icon_globe'),
|
||||
'heroicon-o-users' => __('company_history.icon_users'),
|
||||
'heroicon-o-trophy' => __('company_history.icon_trophy'),
|
||||
'heroicon-o-map' => __('company_history.icon_map'),
|
||||
'heroicon-o-light-bulb' => __('company_history.icon_light_bulb'),
|
||||
'heroicon-o-rocket-launch' => __('company_history.icon_rocket'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function quarterOptions(): array
|
||||
{
|
||||
return [
|
||||
'Q1' => __('company_history.quarter_q1'),
|
||||
'Q2' => __('company_history.quarter_q2'),
|
||||
'Q3' => __('company_history.quarter_q3'),
|
||||
'Q4' => __('company_history.quarter_q4'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function colorPresets(): array
|
||||
{
|
||||
return [
|
||||
'#17a2b8' => __('company_history.color_teal'),
|
||||
'#f5a623' => __('company_history.color_orange'),
|
||||
'#e91e63' => __('company_history.color_pink'),
|
||||
'#2c3e50' => __('company_history.color_navy'),
|
||||
'#5dade2' => __('company_history.color_blue'),
|
||||
'#27ae60' => __('company_history.color_green'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->columns(3)
|
||||
->schema([
|
||||
Section::make(__('company_history.content_section'))
|
||||
->schema([
|
||||
TextInput::make('year')
|
||||
->label(__('company_history.year_field'))
|
||||
->required()
|
||||
->numeric()
|
||||
->minValue(1900)
|
||||
->maxValue(2100),
|
||||
|
||||
Select::make('quarter')
|
||||
->label(__('company_history.quarter_field'))
|
||||
->options(self::quarterOptions())
|
||||
->default('Q1')
|
||||
->required()
|
||||
->native(false),
|
||||
|
||||
TextInput::make('title')
|
||||
->label(__('company_history.title_field'))
|
||||
->required()
|
||||
->maxLength(255),
|
||||
|
||||
Textarea::make('content')
|
||||
->label(__('company_history.content_field'))
|
||||
->required()
|
||||
->rows(5)
|
||||
->columnSpanFull(),
|
||||
|
||||
TranslationTabs::make([
|
||||
'title' => [
|
||||
'type' => 'text',
|
||||
'label' => __('company_history.title_field'),
|
||||
'required' => false,
|
||||
'maxLength' => 255,
|
||||
],
|
||||
'content' => [
|
||||
'type' => 'textarea',
|
||||
'label' => __('company_history.content_field'),
|
||||
'required' => false,
|
||||
'rows' => 5,
|
||||
],
|
||||
]),
|
||||
])
|
||||
->columnSpan(2)
|
||||
->collapsible(false),
|
||||
|
||||
Section::make(__('company_history.settings_section'))
|
||||
->schema([
|
||||
Select::make('color')
|
||||
->label(__('company_history.color_field'))
|
||||
->options(self::colorPresets())
|
||||
->default('#17a2b8')
|
||||
->required()
|
||||
->native(false)
|
||||
->searchable(),
|
||||
|
||||
Select::make('icon')
|
||||
->label(__('company_history.icon_field'))
|
||||
->options(self::iconOptions())
|
||||
->default('heroicon-o-building-office-2')
|
||||
->required()
|
||||
->native(false)
|
||||
->searchable(),
|
||||
|
||||
Select::make('position')
|
||||
->label(__('company_history.position_field'))
|
||||
->options([
|
||||
'' => __('company_history.position_auto'),
|
||||
'left' => __('company_history.position_left'),
|
||||
'right' => __('company_history.position_right'),
|
||||
])
|
||||
->default(null),
|
||||
|
||||
TextInput::make('sort_order')
|
||||
->label(__('company_history.sort_order_field'))
|
||||
->numeric()
|
||||
->default(0)
|
||||
->minValue(0),
|
||||
|
||||
Checkbox::make('is_active')
|
||||
->label(__('company_history.is_active_field'))
|
||||
->default(true),
|
||||
])
|
||||
->columnSpan(1),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\CompanyHistoryItems\Tables;
|
||||
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\ForceDeleteBulkAction;
|
||||
use Filament\Actions\RestoreBulkAction;
|
||||
use Filament\Tables\Columns\ColorColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\ToggleColumn;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Filters\TrashedFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class CompanyHistoryItemsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('year')
|
||||
->label(__('company_history.table_year'))
|
||||
->sortable()
|
||||
->alignCenter(),
|
||||
|
||||
TextColumn::make('quarter')
|
||||
->label(__('company_history.quarter_field'))
|
||||
->badge()
|
||||
->sortable()
|
||||
->alignCenter(),
|
||||
|
||||
ColorColumn::make('color')
|
||||
->label(__('company_history.color_field')),
|
||||
|
||||
TextColumn::make('title')
|
||||
->label(__('company_history.table_title'))
|
||||
->searchable()
|
||||
->sortable()
|
||||
->limit(40),
|
||||
|
||||
TextColumn::make('content')
|
||||
->label(__('company_history.content_field'))
|
||||
->limit(60)
|
||||
->toggleable(),
|
||||
|
||||
TextColumn::make('position')
|
||||
->label(__('company_history.position_field'))
|
||||
->formatStateUsing(fn (?string $state): string => match ($state) {
|
||||
'left' => __('company_history.position_left'),
|
||||
'right' => __('company_history.position_right'),
|
||||
default => __('company_history.position_auto'),
|
||||
})
|
||||
->toggleable(),
|
||||
|
||||
ToggleColumn::make('is_active')
|
||||
->label(__('company_history.table_is_active'))
|
||||
->alignCenter(),
|
||||
|
||||
TextColumn::make('sort_order')
|
||||
->label(__('company_history.sort_order_field'))
|
||||
->sortable()
|
||||
->alignCenter(),
|
||||
|
||||
TextColumn::make('created_at')
|
||||
->label(__('company_history.table_created_at'))
|
||||
->dateTime('d.m.Y H:i')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
TextColumn::make('updated_at')
|
||||
->label(__('company_history.table_updated_at'))
|
||||
->dateTime('d.m.Y H:i')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
TernaryFilter::make('is_active')
|
||||
->label(__('company_history.is_active_field')),
|
||||
TrashedFilter::make(),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make()
|
||||
->label(__('company_history.edit')),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make()
|
||||
->label(__('company_history.delete')),
|
||||
RestoreBulkAction::make()
|
||||
->label(__('company_history.restore')),
|
||||
ForceDeleteBulkAction::make()
|
||||
->label(__('company_history.force_delete')),
|
||||
]),
|
||||
])
|
||||
->defaultSort('year', 'asc')
|
||||
->reorderable('sort_order');
|
||||
}
|
||||
}
|
||||
@@ -1,598 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\InternApplications;
|
||||
|
||||
use App\Models\CareerApplication;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\MarkdownEditor;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Livewire;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Filament\Schemas\Components\Utilities\Set;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class InternApplicationResource extends Resource
|
||||
{
|
||||
protected static ?string $model = CareerApplication::class;
|
||||
|
||||
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-academic-cap';
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('career.internship_title', ['default' => 'Staj Başvuruları']);
|
||||
}
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('career.internship', ['default' => 'Staj Başvurusu']);
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return __('career.internship_title', ['default' => 'Staj Başvuruları']);
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()->where('type', 'internship');
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Tabs::make('Tabs')
|
||||
->tabs([
|
||||
Tab::make('Kişisel ve Başvuru Bilgileri')
|
||||
->icon('heroicon-m-user')
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->label(__('career.name'))
|
||||
->required()
|
||||
->disabled(),
|
||||
|
||||
TextInput::make('email')
|
||||
->label(__('career.email'))
|
||||
->email()
|
||||
->required()
|
||||
->disabled(),
|
||||
|
||||
TextInput::make('phone')
|
||||
->label(__('career.phone'))
|
||||
->disabled(),
|
||||
|
||||
Select::make('status')
|
||||
->label(__('career.status'))
|
||||
->options([
|
||||
'pending' => __('career.pending'),
|
||||
'reviewed' => __('career.reviewed'),
|
||||
'rejected' => __('career.rejected'),
|
||||
'accepted' => __('career.accepted'),
|
||||
'waiting_document' => __('career.waiting_document'),
|
||||
])
|
||||
->required(),
|
||||
|
||||
Textarea::make('message')
|
||||
->label(__('career.message'))
|
||||
->disabled()
|
||||
->columnSpanFull(),
|
||||
])->columns(2),
|
||||
|
||||
Tab::make('Staj Belgeleri & Giriş Bilgileri')
|
||||
->icon('heroicon-m-document-text')
|
||||
->schema([
|
||||
FileUpload::make('cv_path')
|
||||
->label(__('career.cv'))
|
||||
->disk('public')
|
||||
->directory('cvs')
|
||||
->required()
|
||||
->disabled()
|
||||
->downloadable(),
|
||||
|
||||
TextInput::make('username')
|
||||
->label('Kullanıcı Adı')
|
||||
->default(fn ($record) => $record?->email)
|
||||
->disabled()
|
||||
->dehydrated()
|
||||
->autocomplete('new-username'),
|
||||
|
||||
TextInput::make('password')
|
||||
->label('Şifre')
|
||||
->password()
|
||||
->revealable()
|
||||
->autocomplete('new-password')
|
||||
->formatStateUsing(fn () => null)
|
||||
->dehydrateStateUsing(fn ($state) => filled($state) ? Hash::make($state) : null)
|
||||
->dehydrated(fn ($state) => filled($state))
|
||||
->placeholder('Şifreyi değiştirmek istemiyorsanız boş bırakın')
|
||||
->nullable()
|
||||
->suffixAction(
|
||||
\Filament\Actions\Action::make('generatePassword')
|
||||
->icon('heroicon-m-arrow-path')
|
||||
->action(fn (Set $set) => $set('password', Str::random(12)))
|
||||
),
|
||||
|
||||
FileUpload::make('to_be_signed_internship_form_path')
|
||||
->label('İmzalanacak Staj Formu (Stajyerden)')
|
||||
->disk('public')
|
||||
->directory('to_be_signed_interns')
|
||||
->downloadable()
|
||||
->nullable(),
|
||||
|
||||
FileUpload::make('signed_internship_form_path')
|
||||
->label('İmzalı Staj Formu')
|
||||
->disk('public')
|
||||
->directory('signed_interns')
|
||||
->downloadable()
|
||||
->live()
|
||||
->afterStateUpdated(function ($state, Set $set) {
|
||||
if ($state) {
|
||||
$set('status', 'accepted');
|
||||
}
|
||||
})
|
||||
->nullable(),
|
||||
|
||||
DatePicker::make('internship_start_date')
|
||||
->label('Staj Başlangıç Tarihi')
|
||||
->live()
|
||||
->afterStateUpdated(function ($state, $get, Set $set) {
|
||||
if ($state && $get('internship_total_days')) {
|
||||
self::calculateEndDate($state, $get('internship_total_days'), $set);
|
||||
} elseif ($state && $get('internship_end_date')) {
|
||||
self::calculateTotalDays($state, $get('internship_end_date'), $set);
|
||||
}
|
||||
})
|
||||
->nullable(),
|
||||
|
||||
DatePicker::make('internship_end_date')
|
||||
->label('Staj Bitiş Tarihi')
|
||||
->live()
|
||||
->afterStateUpdated(fn ($state, $get, Set $set) => self::calculateTotalDays($get('internship_start_date'), $state, $set))
|
||||
->nullable(),
|
||||
|
||||
TextInput::make('internship_total_days')
|
||||
->label('Toplam Staj Süresi (İş Günü)')
|
||||
->numeric()
|
||||
->live()
|
||||
->afterStateUpdated(fn ($state, $get, Set $set) => self::calculateEndDate($get('internship_start_date'), $state, $set))
|
||||
->nullable(),
|
||||
])->columns(2),
|
||||
|
||||
Tab::make('Staj Günlüğü')
|
||||
->icon('heroicon-m-squares-plus')
|
||||
->schema([
|
||||
TextInput::make('github_repo')
|
||||
->label('GitHub Depo URL\'si')
|
||||
->url()
|
||||
->nullable()
|
||||
->live(),
|
||||
|
||||
Livewire::make(\App\Livewire\InternJournalTimeline::class)
|
||||
->columnSpanFull()
|
||||
]),
|
||||
|
||||
Tab::make('Staj Defteri & Onay')
|
||||
->icon('heroicon-o-book-open')
|
||||
->schema([
|
||||
\Filament\Forms\Components\Placeholder::make('notebook_view')
|
||||
->label('Doldurulan Staj Defteri')
|
||||
->content(function ($record) {
|
||||
if (!$record) return 'Henüz başvuru bulunmamaktadır.';
|
||||
$days = \App\Http\Controllers\CareerController::getInternshipDates($record->internship_start_date, $record->internship_total_days);
|
||||
if (empty($days)) return 'Staj başlangıç tarihi veya süresi girilmemiş.';
|
||||
|
||||
$saved = $record->journalEntries()->get()->keyBy('day_number');
|
||||
|
||||
$html = '<style>
|
||||
.rich-text-content p { margin-bottom: 8px; }
|
||||
.rich-text-content ul { list-style-type: disc; padding-left: 20px; margin-bottom: 8px; }
|
||||
.rich-text-content ol { list-style-type: decimal; padding-left: 20px; margin-bottom: 8px; }
|
||||
.rich-text-content li { margin-bottom: 4px; }
|
||||
</style>';
|
||||
$html .= '<div class="space-y-4" style="max-height: 400px; overflow-y: auto; padding-right: 10px; border: 1px solid #cbd5e1; border-radius: 8px; padding: 15px;">';
|
||||
foreach ($days as $d) {
|
||||
$dayNum = $d['day_number'];
|
||||
$dateF = $d['formatted_date'];
|
||||
$entry = $saved->get($dayNum);
|
||||
$content = $entry ? $entry->content : '';
|
||||
$isRetro = $entry ? $entry->is_retroactive : false;
|
||||
$updatedAt = $entry ? $entry->updated_at->format('d.m.Y H:i') : null;
|
||||
|
||||
$html .= '<div style="margin-bottom: 12px; padding: 12px; border: 1px solid #e2e8f0; border-radius: 8px; background: #f8fafc;">';
|
||||
$html .= ' <div style="display:flex; justify-content:between; font-size:11px; font-weight:700; color:#475569; border-bottom:1px solid #e2e8f0; padding-bottom:6px; margin-bottom:8px;">';
|
||||
$html .= ' <span style="font-weight: 800; color: #2563eb;">' . $dayNum . '. Gün Raporu</span>';
|
||||
if ($isRetro) {
|
||||
$html .= ' <span style="margin-left: 10px; background: #fee2e2; color: #991b1b; padding: 1px 6px; border-radius: 4px; font-size: 9px; font-weight: 800;">GERİYE DÖNÜK KAYIT</span>';
|
||||
}
|
||||
if ($updatedAt) {
|
||||
$html .= ' <span style="margin-left: auto;">Son Güncelleme: ' . $updatedAt . '</span>';
|
||||
} else {
|
||||
$html .= ' <span style="margin-left: auto;">' . $dateF . '</span>';
|
||||
}
|
||||
$html .= ' </div>';
|
||||
|
||||
$cleanContent = $content ? strip_tags($content, ['p', 'strong', 'ul', 'li', 'em', 'br', 'b', 'i', 'ol', 'span']) : '<em style="color:#94a3b8;">Rapor yazılmamış</em>';
|
||||
$html .= ' <div class="rich-text-content" style="font-size:12px; color:#1e293b; line-height:1.5;">' . $cleanContent . '</div>';
|
||||
|
||||
if ($entry && trim($content) !== '') {
|
||||
$supApproved = $entry->supervisor_approved;
|
||||
$supName = $entry->supervisor_name;
|
||||
|
||||
$html .= ' <div style="display:flex; align-items:center; gap:12px; margin-top:12px; padding-top:10px; border-top:1px dashed #e2e8f0; font-size:11px;">';
|
||||
|
||||
// Supervisor approval
|
||||
$supBg = $supApproved ? '#d1fae5' : '#f1f5f9';
|
||||
$supColor = $supApproved ? '#065f46' : '#64748b';
|
||||
$supText = $supApproved ? 'Sorumlu Onayladı' . ($supName ? ' (Onaylayan: ' . e($supName) . ')' : '') : 'Sorumlu Onayı Bekliyor';
|
||||
$html .= ' <span id="sup-badge-' . $entry->id . '" style="background:' . $supBg . '; color:' . $supColor . '; padding: 2px 8px; border-radius: 4px; font-weight: 700;">' . $supText . '</span>';
|
||||
|
||||
// Buttons
|
||||
$supBtnText = $supApproved ? 'Onayı Kaldır' : 'Onayla';
|
||||
$supBtnBg = $supApproved ? '#ef4444' : '#2563eb';
|
||||
$html .= ' <button type="button" onclick="toggleApproval(' . $entry->id . ', this)" style="margin-left:auto; background:' . $supBtnBg . '; color:white; border:none; padding:4px 10px; border-radius:6px; font-weight:bold; cursor:pointer; font-size:10px;">Sorumlu ' . $supBtnText . '</button>';
|
||||
|
||||
$html .= ' </div>';
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
// JS handler
|
||||
$html .= '
|
||||
<script>
|
||||
if (typeof window.toggleApproval !== "function") {
|
||||
window.toggleApproval = function(entryId, btn) {
|
||||
btn.disabled = true;
|
||||
btn.style.opacity = "0.5";
|
||||
|
||||
fetch("' . route('intern.admin.toggle-journal-approval') . '", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-TOKEN": "' . csrf_token() . '"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
entry_id: entryId
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
const badge = document.getElementById("sup-badge-" + entryId);
|
||||
if (data.status) {
|
||||
badge.style.background = "#d1fae5";
|
||||
badge.style.color = "#065f46";
|
||||
badge.textContent = "Sorumlu Onayladı (Onaylayan: " + data.supervisor_name + ")";
|
||||
btn.textContent = "Sorumlu Onayı Kaldır";
|
||||
btn.style.background = "#ef4444";
|
||||
} else {
|
||||
badge.style.background = "#f1f5f9";
|
||||
badge.style.color = "#64748b";
|
||||
badge.textContent = "Sorumlu Onayı Bekliyor";
|
||||
btn.textContent = "Sorumlu Onayla";
|
||||
btn.style.background = "#2563eb";
|
||||
}
|
||||
} else {
|
||||
alert(data.message || "Bir hata oluştu.");
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
alert("Bağlantı hatası oluştu.");
|
||||
})
|
||||
.finally(() => {
|
||||
btn.disabled = false;
|
||||
btn.style.opacity = "1";
|
||||
});
|
||||
};
|
||||
}
|
||||
</script>
|
||||
';
|
||||
|
||||
// Add preview buttons
|
||||
$html .= '<div style="margin-top: 15px; display: flex; gap: 10px;">';
|
||||
$html .= ' <a href="' . route('intern.print-journal') . '?size=a4&intern_id=' . $record->id . '" target="_blank" style="display:inline-flex; align-items:center; justify-content:center; padding: 8px 16px; background:#2563eb; color:white; border-radius:8px; font-weight:bold; font-size:12px; text-decoration:none; box-shadow: 0 1px 3px rgba(37, 99, 235, 0.2);">A4 Defteri Önizle / Yazdır</a>';
|
||||
$html .= ' <a href="' . route('intern.print-journal') . '?size=a5&intern_id=' . $record->id . '" target="_blank" style="display:inline-flex; align-items:center; justify-content:center; padding: 8px 16px; background:#4b5563; color:white; border-radius:8px; font-weight:bold; font-size:12px; text-decoration:none; box-shadow: 0 1px 3px rgba(75, 85, 99, 0.2);">A5 Defteri Önizle / Yazdır</a>';
|
||||
$html .= '</div>';
|
||||
|
||||
return new \Illuminate\Support\HtmlString($html);
|
||||
})
|
||||
->columnSpanFull(),
|
||||
|
||||
\Filament\Schemas\Components\Section::make('Onay ve İmza Bilgileri')
|
||||
->schema([
|
||||
\Filament\Forms\Components\Toggle::make('notebook_supervisor_signed')
|
||||
->label('Staj Sorumlusu İmzala / Onayla')
|
||||
->live(),
|
||||
TextInput::make('notebook_supervisor_name')
|
||||
->label('Staj Sorumlusu Adı / Ünvanı')
|
||||
->placeholder('Örn: Alperen Trunç')
|
||||
->default('Alperen Trunç'),
|
||||
\Filament\Forms\Components\Toggle::make('notebook_approved')
|
||||
->label('Staj Defterini Genel Olarak Onayla')
|
||||
->columnSpanFull(),
|
||||
])->columns(2),
|
||||
]),
|
||||
|
||||
Tab::make('Sertifika & Transkript')
|
||||
->icon('heroicon-o-academic-cap')
|
||||
->schema([
|
||||
TextInput::make('certificate_code')
|
||||
->label('Doğrulama Kodu')
|
||||
->helperText('Belge kaydedildiğinde benzersiz doğrulama kodu otomatik olarak üretilir.')
|
||||
->readonly()
|
||||
->nullable(),
|
||||
|
||||
MarkdownEditor::make('transcript_markdown')
|
||||
->label('Akademik Transkript (Markdown)')
|
||||
->columnSpanFull()
|
||||
->default(function () {
|
||||
return "### STAJ AKADEMİK TRANSKRİPTİ VE PERFORMANS RAPORU\n\n" .
|
||||
"#### 🛠️ Deneyimlenen Teknolojiler ve Kazanımlar\n" .
|
||||
"| Modül / Çalışma Alanı | Kullanılan Teknolojiler / Araçlar | Değerlendirme |\n" .
|
||||
"| --- | --- | --- |\n" .
|
||||
"| Backend Mimari & API | Laravel framework, RESTful API, MySQL | Başarılı |\n" .
|
||||
"| Arayüz & UI/UX Uygulamaları | Flutter, CSS, Glassmorphic Tasarım Prensipleri | Üstün Başarı |\n" .
|
||||
"| Masaüstü & Sistem Entegrasyonu | Electron.js, Git / GitHub | Başarılı |\n" .
|
||||
"| Takım Çalışması & Proje Yönetimi | Agile / Scrum, Slack, JIRA | Başarılı |\n\n" .
|
||||
"#### 📊 Performans Değerlendirme Kriterleri\n" .
|
||||
"| Değerlendirme Kriteri | Puan (100 Üzerinden) | Harf Notu |\n" .
|
||||
"| --- | --- | --- |\n" .
|
||||
"| Teknik Sorumluluk ve Görev Bilinci | 95 | AA |\n" .
|
||||
"| Problem Çözme ve Analitik Düşünme | 90 | BA |\n" .
|
||||
"| Ekip Çalışması ve İletişim Uyum | 95 | AA |\n" .
|
||||
"| Öğrenme Hızı ve Adaptasyon | 100 | AA |\n" .
|
||||
"| **GENEL BAŞARI ORTALAMASI** | **95.00** | **AA (Mükemmel)** |\n\n" .
|
||||
"#### 📝 Danışman Görüşü ve Değerlendirme Notu\n" .
|
||||
"\"Stajyerimiz, staj süresi boyunca kendisine verilen görevleri büyük bir titizlikle yerine getirmiştir. Özellikle karşılaştığı teknik problemlere getirdiği pratik çözümler ve yeni teknolojileri öğrenme isteği takdir edilmeye değerdir. Kurumumuz bünyesinde yürüttüğümüz projelere sağladığı katkılardan ötürü teşekkür eder, profesyonel kariyerinde başarılar dileriz.\"";
|
||||
}),
|
||||
])->columns(1),
|
||||
|
||||
Tab::make('Stajyer Blog Yazıları')
|
||||
->icon('heroicon-o-newspaper')
|
||||
->schema([
|
||||
\Filament\Forms\Components\Placeholder::make('intern_blogs_view')
|
||||
->label('Yazılan Blog Yazıları')
|
||||
->content(function ($record) {
|
||||
if (!$record) return 'Henüz kayıt bulunmuyor.';
|
||||
$blogs = $record->blogs()->orderBy('created_at', 'desc')->get();
|
||||
if ($blogs->isEmpty()) {
|
||||
return new \Illuminate\Support\HtmlString('<div style="padding: 15px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; color: #64748b; font-size: 13px;">Bu stajyer henüz blog yazısı oluşturmadı.</div>');
|
||||
}
|
||||
|
||||
$html = '<div style="space-y: 12px;">';
|
||||
foreach ($blogs as $b) {
|
||||
$catLabel = match ($b->intern_category) {
|
||||
'experience' => '1. Staj Tecrübesi',
|
||||
'technical_challenge' => '2. Teknik Zorluklar',
|
||||
'product_showcase' => '3. Ürün Tanıtımı',
|
||||
default => $b->intern_category ?? '-',
|
||||
};
|
||||
$statusBg = match ($b->status) {
|
||||
'published' => '#d1fae5',
|
||||
'pending' => '#fef3c7',
|
||||
'rejected' => '#fee2e2',
|
||||
default => '#f1f5f9',
|
||||
};
|
||||
$statusColor = match ($b->status) {
|
||||
'published' => '#065f46',
|
||||
'pending' => '#92400e',
|
||||
'rejected' => '#991b1b',
|
||||
default => '#475569',
|
||||
};
|
||||
$statusText = match ($b->status) {
|
||||
'published' => 'Yayınlandı',
|
||||
'pending' => 'Onay Bekliyor',
|
||||
'rejected' => 'Revize İstendi',
|
||||
default => 'Taslak',
|
||||
};
|
||||
|
||||
$html .= '<div style="padding: 14px; border: 1px solid #e2e8f0; border-radius: 10px; background: #ffffff; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center;">';
|
||||
$html .= ' <div>';
|
||||
$html .= ' <div style="display: flex; gap: 8px; align-items: center; margin-bottom: 4px;">';
|
||||
$html .= ' <span style="font-size: 11px; font-weight: 800; background: #eff6ff; color: #1d4ed8; padding: 2px 8px; border-radius: 6px;">' . e($catLabel) . '</span>';
|
||||
$html .= ' <span style="font-size: 11px; font-weight: 800; background: ' . $statusBg . '; color: ' . $statusColor . '; padding: 2px 8px; border-radius: 6px;">' . $statusText . '</span>';
|
||||
$html .= ' </div>';
|
||||
$html .= ' <h4 style="margin: 0; font-size: 14px; font-weight: bold; color: #1e293b;">' . e($b->title) . '</h4>';
|
||||
if ($b->admin_feedback) {
|
||||
$html .= ' <p style="margin: 4px 0 0 0; font-size: 11px; color: #dc2626;"><strong>Revizyon Notu:</strong> ' . e($b->admin_feedback) . '</p>';
|
||||
}
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div>';
|
||||
if ($b->status === 'published') {
|
||||
$html .= ' <a href="/blog/' . $b->slug . '" target="_blank" style="padding: 6px 12px; background: #059669; color: white; border-radius: 6px; font-size: 11px; font-weight: bold; text-decoration: none;">Sitede Gör</a>';
|
||||
}
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return new \Illuminate\Support\HtmlString($html);
|
||||
})
|
||||
->columnSpanFull(),
|
||||
])
|
||||
])->columnSpanFull()
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label(__('career.name'))
|
||||
->searchable()
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('email')
|
||||
->label(__('career.email'))
|
||||
->searchable()
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('phone')
|
||||
->label(__('career.phone'))
|
||||
->searchable(),
|
||||
|
||||
TextColumn::make('status')
|
||||
->label(__('career.status'))
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'pending' => 'gray',
|
||||
'reviewed' => 'info',
|
||||
'rejected' => 'danger',
|
||||
'accepted' => 'success',
|
||||
'waiting_document' => 'warning',
|
||||
default => 'gray',
|
||||
})
|
||||
->formatStateUsing(fn (string $state): string => __("career.{$state}")),
|
||||
|
||||
TextColumn::make('certificate_code')
|
||||
->label('Sertifika Kodu')
|
||||
->searchable()
|
||||
->placeholder('Yok'),
|
||||
|
||||
TextColumn::make('created_at')
|
||||
->label(__('career.created_at'))
|
||||
->dateTime('d.m.Y H:i')
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('status')
|
||||
->label(__('career.status'))
|
||||
->options([
|
||||
'pending' => __('career.pending'),
|
||||
'reviewed' => __('career.reviewed'),
|
||||
'rejected' => __('career.rejected'),
|
||||
'accepted' => __('career.accepted'),
|
||||
'waiting_document' => __('career.waiting_document'),
|
||||
]),
|
||||
])
|
||||
->actions([
|
||||
Action::make('view_certificate')
|
||||
->label('Sertifika & Transkript')
|
||||
->icon('heroicon-o-academic-cap')
|
||||
->color('success')
|
||||
->url(fn ($record) => $record->certificate_code ? route('internship.verify', $record->certificate_code) : null)
|
||||
->visible(fn ($record) => !empty($record->certificate_code))
|
||||
->openUrlInNewTab(),
|
||||
Action::make('print_journal_a4')
|
||||
->label('A4 Defter')
|
||||
->icon('heroicon-o-printer')
|
||||
->color('info')
|
||||
->url(fn ($record) => route('intern.print-journal') . '?size=a4&intern_id=' . $record->id)
|
||||
->visible(fn ($record) => !empty($record->internship_total_days))
|
||||
->openUrlInNewTab(),
|
||||
Action::make('print_journal_a5')
|
||||
->label('A5 Defter')
|
||||
->icon('heroicon-o-printer')
|
||||
->color('gray')
|
||||
->url(fn ($record) => route('intern.print-journal') . '?size=a5&intern_id=' . $record->id)
|
||||
->visible(fn ($record) => !empty($record->internship_total_days))
|
||||
->openUrlInNewTab(),
|
||||
Action::make('download_markdown')
|
||||
->label('Günlük (.md)')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->color('warning')
|
||||
->url(fn ($record) => route('intern.download-journal') . '?intern_id=' . $record->id)
|
||||
->visible(fn ($record) => !empty($record->github_repo))
|
||||
->openUrlInNewTab(),
|
||||
Action::make('download_signed_form')
|
||||
->label('İmzalı Form')
|
||||
->icon('heroicon-o-document-check')
|
||||
->url(fn ($record) => $record->signed_internship_form_path ? Storage::disk('public')->url($record->signed_internship_form_path) : null)
|
||||
->visible(fn ($record) => !empty($record->signed_internship_form_path))
|
||||
->openUrlInNewTab(),
|
||||
Action::make('download_cv')
|
||||
->label(__('career.download_cv'))
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->url(fn ($record) => Storage::disk('public')->url($record->cv_path))
|
||||
->openUrlInNewTab(),
|
||||
DeleteAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
])
|
||||
->defaultSort('created_at', 'desc');
|
||||
}
|
||||
|
||||
public static function calculateTotalDays($start, $end, Set $set): void
|
||||
{
|
||||
if (!$start || !$end) {
|
||||
$set('internship_total_days', null);
|
||||
return;
|
||||
}
|
||||
|
||||
$startDate = \Carbon\Carbon::parse($start);
|
||||
$endDate = \Carbon\Carbon::parse($end);
|
||||
|
||||
if ($startDate->gt($endDate)) {
|
||||
$set('internship_total_days', 0);
|
||||
return;
|
||||
}
|
||||
|
||||
$days = 0;
|
||||
while ($startDate->lte($endDate)) {
|
||||
if (!$startDate->isWeekend() && !\App\Helpers\TurkeyHolidayHelper::isHoliday($startDate)) {
|
||||
$days++;
|
||||
}
|
||||
$startDate->addDay();
|
||||
}
|
||||
|
||||
$set('internship_total_days', $days);
|
||||
}
|
||||
|
||||
public static function calculateEndDate($start, $totalDays, Set $set): void
|
||||
{
|
||||
if (!$start || !$totalDays || $totalDays <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$startDate = \Carbon\Carbon::parse($start);
|
||||
$daysToAdd = intval($totalDays);
|
||||
|
||||
$endDate = $startDate->copy();
|
||||
$count = 0;
|
||||
$temp = $startDate->copy();
|
||||
|
||||
while ($count < $daysToAdd) {
|
||||
if ($temp->isWeekend() || \App\Helpers\TurkeyHolidayHelper::isHoliday($temp)) {
|
||||
$temp->addDay();
|
||||
continue;
|
||||
}
|
||||
$endDate = $temp->copy();
|
||||
$temp->addDay();
|
||||
$count++;
|
||||
}
|
||||
|
||||
$set('internship_end_date', $endDate->format('Y-m-d'));
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListInternApplications::route('/'),
|
||||
'create' => Pages\CreateInternApplication::route('/create'),
|
||||
'edit' => Pages\EditInternApplication::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\InternApplications\Pages;
|
||||
|
||||
use App\Filament\Admin\Resources\InternApplications\InternApplicationResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateInternApplication extends CreateRecord
|
||||
{
|
||||
protected static string $resource = InternApplicationResource::class;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\InternApplications\Pages;
|
||||
|
||||
use App\Filament\Admin\Resources\InternApplications\InternApplicationResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListInternApplications extends ListRecords
|
||||
{
|
||||
protected static string $resource = InternApplicationResource::class;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
<?php
|
||||
// Bu bileşen devre dışı bırakılmıştır.
|
||||
@@ -1,218 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\JobApplications;
|
||||
|
||||
use App\Models\CareerApplication;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class JobApplicationResource extends Resource
|
||||
{
|
||||
protected static ?string $model = CareerApplication::class;
|
||||
|
||||
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-briefcase';
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('career.job_application_title', ['default' => 'İş Başvuruları']);
|
||||
}
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('career.job', ['default' => 'İş Başvurusu']);
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return __('career.job_application_title', ['default' => 'İş Başvuruları']);
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()->where('type', 'job');
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('name')
|
||||
->label(__('career.name'))
|
||||
->required()
|
||||
->disabled(),
|
||||
|
||||
TextInput::make('email')
|
||||
->label(__('career.email'))
|
||||
->email()
|
||||
->required()
|
||||
->disabled(),
|
||||
|
||||
TextInput::make('phone')
|
||||
->label(__('career.phone'))
|
||||
->disabled(),
|
||||
|
||||
Select::make('status')
|
||||
->label(__('career.status'))
|
||||
->options([
|
||||
'pending' => __('career.pending'),
|
||||
'reviewed' => __('career.reviewed'),
|
||||
'rejected' => __('career.rejected'),
|
||||
'accepted' => __('career.accepted'),
|
||||
'waiting_document' => __('career.waiting_document'),
|
||||
])
|
||||
->required(),
|
||||
|
||||
FileUpload::make('cv_path')
|
||||
->label(__('career.cv'))
|
||||
->disk('public')
|
||||
->directory('cvs')
|
||||
->required()
|
||||
->disabled()
|
||||
->downloadable(),
|
||||
|
||||
FileUpload::make('nda_path')
|
||||
->label(__('career.nda'))
|
||||
->disk('public')
|
||||
->directory('ndas')
|
||||
->disabled()
|
||||
->downloadable(),
|
||||
|
||||
FileUpload::make('contract_path')
|
||||
->label(__('career.contract'))
|
||||
->disk('public')
|
||||
->directory('contracts')
|
||||
->disabled()
|
||||
->downloadable(),
|
||||
|
||||
FileUpload::make('id_photocopy_path')
|
||||
->label(__('career.id_photocopy', ['default' => 'Kimlik Fotokopisi']))
|
||||
->disk('public')
|
||||
->directory('id_photocopies')
|
||||
->disabled()
|
||||
->downloadable(),
|
||||
|
||||
Toggle::make('git_knowledge')
|
||||
->label(__('career.git_knowledge'))
|
||||
->disabled(),
|
||||
|
||||
Toggle::make('ai_usage')
|
||||
->label(__('career.ai_usage'))
|
||||
->disabled(),
|
||||
|
||||
Textarea::make('message')
|
||||
->label(__('career.message'))
|
||||
->disabled()
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label(__('career.name'))
|
||||
->searchable()
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('email')
|
||||
->label(__('career.email'))
|
||||
->searchable()
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('phone')
|
||||
->label(__('career.phone'))
|
||||
->searchable(),
|
||||
|
||||
TextColumn::make('status')
|
||||
->label(__('career.status'))
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'pending' => 'gray',
|
||||
'reviewed' => 'info',
|
||||
'rejected' => 'danger',
|
||||
'accepted' => 'success',
|
||||
'waiting_document' => 'warning',
|
||||
default => 'gray',
|
||||
})
|
||||
->formatStateUsing(fn (string $state): string => __("career.{$state}")),
|
||||
|
||||
TextColumn::make('git_knowledge')
|
||||
->label(__('career.git_knowledge'))
|
||||
->badge()
|
||||
->color(fn ($state) => $state ? 'success' : 'danger')
|
||||
->formatStateUsing(fn ($state) => $state ? 'Evet' : 'Hayır'),
|
||||
|
||||
TextColumn::make('ai_usage')
|
||||
->label(__('career.ai_usage'))
|
||||
->badge()
|
||||
->color(fn ($state) => $state ? 'success' : 'danger')
|
||||
->formatStateUsing(fn ($state) => $state ? 'Evet' : 'Hayır'),
|
||||
|
||||
TextColumn::make('created_at')
|
||||
->label(__('career.created_at'))
|
||||
->dateTime('d.m.Y H:i')
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('status')
|
||||
->label(__('career.status'))
|
||||
->options([
|
||||
'pending' => __('career.pending'),
|
||||
'reviewed' => __('career.reviewed'),
|
||||
'rejected' => __('career.rejected'),
|
||||
'accepted' => __('career.accepted'),
|
||||
'waiting_document' => __('career.waiting_document'),
|
||||
]),
|
||||
])
|
||||
->actions([
|
||||
Action::make('download_cv')
|
||||
->label(__('career.download_cv'))
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->url(fn ($record) => Storage::disk('public')->url($record->cv_path))
|
||||
->openUrlInNewTab(),
|
||||
Action::make('download_nda')
|
||||
->label(__('career.nda'))
|
||||
->icon('heroicon-o-shield-check')
|
||||
->url(fn ($record) => $record->nda_path ? Storage::disk('public')->url($record->nda_path) : null)
|
||||
->visible(fn ($record) => $record->nda_path !== null)
|
||||
->openUrlInNewTab(),
|
||||
Action::make('download_contract')
|
||||
->label(__('career.contract'))
|
||||
->icon('heroicon-o-document-text')
|
||||
->url(fn ($record) => $record->contract_path ? Storage::disk('public')->url($record->contract_path) : null)
|
||||
->visible(fn ($record) => $record->contract_path !== null)
|
||||
->openUrlInNewTab(),
|
||||
DeleteAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
])
|
||||
->defaultSort('created_at', 'desc');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListJobApplications::route('/'),
|
||||
'create' => Pages\CreateJobApplication::route('/create'),
|
||||
'edit' => Pages\EditJobApplication::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\JobApplications\Pages;
|
||||
|
||||
use App\Filament\Admin\Resources\JobApplications\JobApplicationResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateJobApplication extends CreateRecord
|
||||
{
|
||||
protected static string $resource = JobApplicationResource::class;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\JobApplications\Pages;
|
||||
|
||||
use App\Filament\Admin\Resources\JobApplications\JobApplicationResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditJobApplication extends EditRecord
|
||||
{
|
||||
protected static string $resource = JobApplicationResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\JobApplications\Pages;
|
||||
|
||||
use App\Filament\Admin\Resources\JobApplications\JobApplicationResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListJobApplications extends ListRecords
|
||||
{
|
||||
protected static string $resource = JobApplicationResource::class;
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\MusicProductions;
|
||||
|
||||
use App\Filament\Admin\Resources\MusicProductions\Pages\CreateMusicProduction;
|
||||
use App\Filament\Admin\Resources\MusicProductions\Pages\EditMusicProduction;
|
||||
use App\Filament\Admin\Resources\MusicProductions\Pages\ListMusicProductions;
|
||||
use App\Filament\Admin\Resources\MusicProductions\Schemas\MusicProductionForm;
|
||||
use App\Filament\Admin\Resources\MusicProductions\Tables\MusicProductionsTable;
|
||||
use App\Models\MusicProduction;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
|
||||
class MusicProductionResource extends Resource
|
||||
{
|
||||
protected static ?string $model = MusicProduction::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-musical-note';
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('music_productions.title');
|
||||
}
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('music_productions.model_label');
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return __('music_productions.plural_model_label');
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return MusicProductionForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return MusicProductionsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListMusicProductions::route('/'),
|
||||
'create' => CreateMusicProduction::route('/create'),
|
||||
'edit' => EditMusicProduction::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getRecordRouteBindingEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getRecordRouteBindingEloquentQuery()
|
||||
->withoutGlobalScopes([
|
||||
SoftDeletingScope::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\MusicProductions\Pages;
|
||||
|
||||
use App\Filament\Admin\Resources\MusicProductions\MusicProductionResource;
|
||||
use App\Filament\Admin\Resources\Components\TranslationTabs;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateMusicProduction extends CreateRecord
|
||||
{
|
||||
protected static string $resource = MusicProductionResource::class;
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return __('music_productions.create');
|
||||
}
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return $this->getResource()::getUrl('index');
|
||||
}
|
||||
|
||||
protected function getCreatedNotificationTitle(): ?string
|
||||
{
|
||||
return __('music_productions.created_successfully');
|
||||
}
|
||||
|
||||
protected function afterCreate(): void
|
||||
{
|
||||
// Save translations
|
||||
TranslationTabs::saveTranslations($this->record, $this->form->getState());
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\MusicProductions\Pages;
|
||||
|
||||
use App\Filament\Admin\Resources\MusicProductions\MusicProductionResource;
|
||||
use App\Filament\Admin\Resources\Components\TranslationTabs;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\ForceDeleteAction;
|
||||
use Filament\Actions\RestoreAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditMusicProduction extends EditRecord
|
||||
{
|
||||
protected static string $resource = MusicProductionResource::class;
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return __('music_productions.edit');
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('save')
|
||||
->label(__('music_productions.save'))
|
||||
->action('save')
|
||||
->keyBindings(['mod+s'])
|
||||
->color('primary')
|
||||
->size('sm'),
|
||||
Action::make('cancel')
|
||||
->label(__('music_productions.cancel'))
|
||||
->url($this->getResource()::getUrl('index'))
|
||||
->color('gray')
|
||||
->size('sm'),
|
||||
DeleteAction::make()
|
||||
->label(__('music_productions.delete'))
|
||||
->size('sm'),
|
||||
RestoreAction::make()
|
||||
->label(__('music_productions.restore'))
|
||||
->size('sm'),
|
||||
ForceDeleteAction::make()
|
||||
->label(__('music_productions.force_delete'))
|
||||
->size('sm'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getFormActions(): array
|
||||
{
|
||||
return []; // Hide standard bottom save/cancel buttons
|
||||
}
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return $this->getResource()::getUrl('index');
|
||||
}
|
||||
|
||||
protected function getSavedNotificationTitle(): ?string
|
||||
{
|
||||
return __('music_productions.updated_successfully');
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeFill(array $data): array
|
||||
{
|
||||
// Load existing translations
|
||||
$translationData = TranslationTabs::fillFromRecord($this->record);
|
||||
|
||||
\Log::info('Loading translations for music production edit', [
|
||||
'production_id' => $this->record->id,
|
||||
'translations' => $translationData
|
||||
]);
|
||||
|
||||
return array_merge($data, $translationData);
|
||||
}
|
||||
|
||||
protected function afterSave(): void
|
||||
{
|
||||
\Log::info('Saving music production translations', [
|
||||
'production_id' => $this->record->id,
|
||||
'form_state' => $this->form->getState()
|
||||
]);
|
||||
|
||||
// Save translations
|
||||
TranslationTabs::saveTranslations($this->record, $this->form->getState());
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\MusicProductions\Pages;
|
||||
|
||||
use App\Console\Commands\SyncSpotifyMusicProductions;
|
||||
use App\Console\Commands\SyncYoutubeReleases;
|
||||
use App\Filament\Admin\Resources\MusicProductions\MusicProductionResource;
|
||||
use App\Services\SpotifyService;
|
||||
use App\Services\YouTubeService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
class ListMusicProductions extends ListRecords
|
||||
{
|
||||
protected static string $resource = MusicProductionResource::class;
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return __('music_productions.title');
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('syncSpotify')
|
||||
->label(__('music_productions.sync_spotify'))
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(__('music_productions.sync_spotify_heading'))
|
||||
->modalDescription(__('music_productions.sync_spotify_description'))
|
||||
->visible(fn (): bool => app(SpotifyService::class)->isConfigured())
|
||||
->action(function (): void {
|
||||
$exitCode = Artisan::call(SyncSpotifyMusicProductions::class);
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
Notification::make()
|
||||
->title(__('music_productions.spotify_sync_failed'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title(__('music_productions.spotify_sync_success'))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Action::make('syncYoutube')
|
||||
->label(__('music_productions.sync_youtube'))
|
||||
->icon('heroicon-o-play')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(__('music_productions.sync_youtube_heading'))
|
||||
->modalDescription(__('music_productions.sync_youtube_description'))
|
||||
->visible(fn (): bool => app(YouTubeService::class)->isConfigured())
|
||||
->action(function (): void {
|
||||
$exitCode = Artisan::call(SyncYoutubeReleases::class);
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
Notification::make()
|
||||
->title(__('music_productions.youtube_sync_failed'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title(__('music_productions.youtube_sync_success'))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
CreateAction::make()
|
||||
->label(__('music_productions.create')),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\MusicProductions\Schemas;
|
||||
|
||||
use App\Filament\Admin\Resources\Components\TranslationTabs;
|
||||
use App\Models\MusicProduction;
|
||||
use Filament\Forms\Components\Checkbox;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\RichEditor;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class MusicProductionForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->columns(3)
|
||||
->schema([
|
||||
// Sol Kolon - Ana İçerik (2 sütun genişliğinde)
|
||||
Section::make(__('music_productions.content_section'))
|
||||
->schema([
|
||||
TextInput::make('title')
|
||||
->label(__('music_productions.title_field'))
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(function (string $operation, $state, callable $set) {
|
||||
if ($operation !== 'create') {
|
||||
return;
|
||||
}
|
||||
$set('slug', Str::slug($state));
|
||||
}),
|
||||
|
||||
TextInput::make('slug')
|
||||
->label(__('music_productions.slug_field'))
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->unique(ignoreRecord: true)
|
||||
->rules(['alpha_dash'])
|
||||
->helperText(__('music_productions.slug_helper')),
|
||||
|
||||
TextInput::make('client_name')
|
||||
->label(__('music_productions.client_name_field'))
|
||||
->maxLength(255),
|
||||
|
||||
RichEditor::make('content')
|
||||
->label(__('music_productions.content_field'))
|
||||
->fileAttachmentsDisk('public')
|
||||
->fileAttachmentsDirectory('music-productions')
|
||||
->fileAttachmentsVisibility('public')
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpan(2)
|
||||
->collapsible(false),
|
||||
|
||||
// Sağ Kolon - Ayarlar (1 sütun genişliğinde)
|
||||
Section::make(__('music_productions.settings_section'))
|
||||
->schema([
|
||||
FileUpload::make('cover_image')
|
||||
->label(__('music_productions.cover_image_field'))
|
||||
->image()
|
||||
->disk('public')
|
||||
->directory('music-productions/covers')
|
||||
->visibility('public')
|
||||
->imageEditor()
|
||||
->imageEditorAspectRatios([
|
||||
'16:9',
|
||||
'4:3',
|
||||
'1:1',
|
||||
])
|
||||
->helperText(__('music_productions.cover_image_helper'))
|
||||
->columnSpanFull(),
|
||||
|
||||
DatePicker::make('production_date')
|
||||
->label(__('music_productions.production_date_field'))
|
||||
->displayFormat('d.m.Y')
|
||||
->helperText(__('music_productions.production_date_helper'))
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('sort_order')
|
||||
->label(__('music_productions.sort_order_field'))
|
||||
->numeric()
|
||||
->default(0)
|
||||
->columnSpanFull(),
|
||||
|
||||
Checkbox::make('is_active')
|
||||
->label(__('music_productions.is_active_field'))
|
||||
->default(true)
|
||||
->helperText(__('music_productions.is_active_helper'))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpan(1)
|
||||
->collapsible(false),
|
||||
|
||||
Section::make(__('music_productions.spotify_section'))
|
||||
->schema([
|
||||
Placeholder::make('spotify_album_id_display')
|
||||
->label(__('music_productions.spotify_album_id_field'))
|
||||
->content(fn (?MusicProduction $record): string => $record?->spotify_album_id ?? '-'),
|
||||
|
||||
Placeholder::make('spotify_type_display')
|
||||
->label(__('music_productions.spotify_type_field'))
|
||||
->content(fn (?MusicProduction $record): string => $record?->spotify_type
|
||||
? __('music_productions.spotify_type_' . $record->spotify_type, [], $record->spotify_type)
|
||||
: '-'),
|
||||
|
||||
Placeholder::make('spotify_url_display')
|
||||
->label(__('music_productions.spotify_url_field'))
|
||||
->content(fn (?MusicProduction $record): string => $record?->spotify_url ?? '-'),
|
||||
|
||||
Placeholder::make('spotify_synced_at_display')
|
||||
->label(__('music_productions.spotify_synced_at_field'))
|
||||
->content(fn (?MusicProduction $record): string => $record?->spotify_synced_at
|
||||
?->timezone(config('app.timezone'))
|
||||
->format('d.m.Y H:i') ?? '-'),
|
||||
])
|
||||
->columnSpanFull()
|
||||
->collapsible(true)
|
||||
->collapsed(true)
|
||||
->visible(fn (?MusicProduction $record): bool => filled($record?->spotify_album_id)),
|
||||
|
||||
Section::make(__('music_productions.youtube_section'))
|
||||
->schema([
|
||||
Placeholder::make('youtube_video_id_display')
|
||||
->label(__('music_productions.youtube_video_id_field'))
|
||||
->content(fn (?MusicProduction $record): string => $record?->youtube_video_id ?? '-'),
|
||||
|
||||
Placeholder::make('youtube_url_display')
|
||||
->label(__('music_productions.youtube_url_field'))
|
||||
->content(fn (?MusicProduction $record): string => $record?->youtube_url ?? '-'),
|
||||
|
||||
Placeholder::make('youtube_description_display')
|
||||
->label(__('music_productions.youtube_description_field'))
|
||||
->content(fn (?MusicProduction $record): string => filled($record?->youtube_description)
|
||||
? \Illuminate\Support\Str::limit($record->youtube_description, 500)
|
||||
: '-'),
|
||||
|
||||
Placeholder::make('youtube_synced_at_display')
|
||||
->label(__('music_productions.youtube_synced_at_field'))
|
||||
->content(fn (?MusicProduction $record): string => $record?->youtube_synced_at
|
||||
?->timezone(config('app.timezone'))
|
||||
->format('d.m.Y H:i') ?? '-'),
|
||||
])
|
||||
->columnSpanFull()
|
||||
->collapsible(true)
|
||||
->collapsed(true)
|
||||
->visible(fn (?MusicProduction $record): bool => filled($record?->youtube_video_id)),
|
||||
|
||||
// Alt Kısım - Galeri Görselleri
|
||||
Section::make(__('music_productions.gallery_field'))
|
||||
->schema([
|
||||
FileUpload::make('gallery')
|
||||
->label(__('music_productions.gallery_field'))
|
||||
->multiple()
|
||||
->image()
|
||||
->disk('public')
|
||||
->directory('music-productions/gallery')
|
||||
->visibility('public')
|
||||
->helperText(__('music_productions.gallery_helper'))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull()
|
||||
->collapsible(true)
|
||||
->collapsed(false),
|
||||
|
||||
// Çeviriler Kısımı (Çoklu Dil)
|
||||
Section::make('🌍 ' . __('music_productions.translations_section'))
|
||||
->schema([
|
||||
TranslationTabs::make([
|
||||
'title' => [
|
||||
'type' => 'text',
|
||||
'label' => __('music_productions.title_field'),
|
||||
'required' => false,
|
||||
'maxLength' => 255,
|
||||
],
|
||||
'client_name' => [
|
||||
'type' => 'text',
|
||||
'label' => __('music_productions.client_name_field'),
|
||||
'required' => false,
|
||||
'maxLength' => 255,
|
||||
],
|
||||
'content' => [
|
||||
'type' => 'richtext',
|
||||
'label' => __('music_productions.content_field'),
|
||||
'required' => false,
|
||||
],
|
||||
]),
|
||||
])
|
||||
->columnSpanFull()
|
||||
->collapsible(true)
|
||||
->collapsed(false),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\MusicProductions\Tables;
|
||||
|
||||
use App\Models\MusicProduction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\ForceDeleteBulkAction;
|
||||
use Filament\Actions\RestoreBulkAction;
|
||||
use Filament\Tables\Columns\ImageColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\ToggleColumn;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class MusicProductionsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
ImageColumn::make('cover_image')
|
||||
->label(__('music_productions.cover_image_field'))
|
||||
->getStateUsing(fn (MusicProduction $record): ?string => $record->cover_image_url)
|
||||
->square()
|
||||
->size(60),
|
||||
|
||||
TextColumn::make('title')
|
||||
->label(__('music_productions.table_title'))
|
||||
->searchable()
|
||||
->sortable()
|
||||
->limit(50),
|
||||
|
||||
TextColumn::make('slug')
|
||||
->label(__('music_productions.table_slug'))
|
||||
->searchable()
|
||||
->sortable()
|
||||
->limit(30),
|
||||
|
||||
TextColumn::make('client_name')
|
||||
->label(__('music_productions.table_client_name'))
|
||||
->searchable()
|
||||
->sortable()
|
||||
->limit(30),
|
||||
|
||||
TextColumn::make('production_date')
|
||||
->label(__('music_productions.table_production_date'))
|
||||
->date('d.m.Y')
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('spotify_type')
|
||||
->label(__('music_productions.spotify_type_field'))
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => filled($state)
|
||||
? __('music_productions.spotify_type_' . $state, [], $state)
|
||||
: '-')
|
||||
->color(fn (?string $state): string => match ($state) {
|
||||
'single' => 'info',
|
||||
'album' => 'success',
|
||||
default => 'gray',
|
||||
})
|
||||
->toggleable(),
|
||||
|
||||
ToggleColumn::make('is_active')
|
||||
->label(__('music_productions.table_is_active'))
|
||||
->alignCenter(),
|
||||
|
||||
TextColumn::make('sort_order')
|
||||
->label(__('music_productions.sort_order_field'))
|
||||
->sortable()
|
||||
->alignCenter(),
|
||||
|
||||
TextColumn::make('created_at')
|
||||
->label(__('music_productions.table_created_at'))
|
||||
->dateTime('d.m.Y H:i')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
TextColumn::make('updated_at')
|
||||
->label(__('music_productions.table_updated_at'))
|
||||
->dateTime('d.m.Y H:i')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
TernaryFilter::make('is_active')
|
||||
->label(__('music_productions.is_active_field')),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make()
|
||||
->label(__('music_productions.edit')),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make()
|
||||
->label(__('music_productions.delete')),
|
||||
RestoreBulkAction::make()
|
||||
->label(__('music_productions.restore')),
|
||||
ForceDeleteBulkAction::make()
|
||||
->label(__('music_productions.force_delete')),
|
||||
]),
|
||||
])
|
||||
->defaultSort('sort_order', 'asc');
|
||||
}
|
||||
}
|
||||
@@ -47,14 +47,8 @@ class BankAccountsSection
|
||||
'GBP' => 'Sterlin (£)',
|
||||
])
|
||||
->default('TRY')
|
||||
->live()
|
||||
->required(),
|
||||
|
||||
TextInput::make('swift_code')
|
||||
->label('SWIFT / BIC')
|
||||
->placeholder('Örn: TRHBTR2A')
|
||||
->required(fn (Get $get): bool => $get('currency') !== 'TRY'),
|
||||
|
||||
FileUpload::make('logo')
|
||||
->label('Banka Logosu')
|
||||
->image()
|
||||
|
||||
@@ -35,6 +35,7 @@ class ContentSection
|
||||
|
||||
RichEditor::make('content')
|
||||
->label(__('pages.content_field'))
|
||||
->required()
|
||||
->fileAttachmentsDisk('public')
|
||||
->fileAttachmentsDirectory('pages')
|
||||
->fileAttachmentsVisibility('public')
|
||||
|
||||
@@ -2,14 +2,12 @@
|
||||
|
||||
namespace App\Filament\Admin\Resources\Pages\Schemas\Sections;
|
||||
|
||||
use App\Support\PageTemplateHero;
|
||||
use Filament\Forms\Components\Checkbox;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
|
||||
class PageSettingsSection
|
||||
{
|
||||
@@ -17,13 +15,6 @@ class PageSettingsSection
|
||||
{
|
||||
return Section::make(__('pages.form_section_page_settings'))
|
||||
->schema([
|
||||
Select::make('template')
|
||||
->label(__('pages.template_field'))
|
||||
->options(PageTemplateHero::templateFormOptions())
|
||||
->default('default')
|
||||
->live()
|
||||
->columnSpanFull(),
|
||||
|
||||
FileUpload::make('featured_image')
|
||||
->label(__('pages.featured_image_field'))
|
||||
->image()
|
||||
@@ -36,13 +27,7 @@ class PageSettingsSection
|
||||
'4:3',
|
||||
'1:1',
|
||||
])
|
||||
->helperText(function (Get $get): string {
|
||||
if (PageTemplateHero::hasHero($get('template'))) {
|
||||
return __('pages.featured_image_template_hero_helper');
|
||||
}
|
||||
|
||||
return __('pages.featured_image_helper_text');
|
||||
})
|
||||
->helperText(__('pages.featured_image_helper_text'))
|
||||
->columnSpanFull(),
|
||||
|
||||
Select::make('author_id')
|
||||
@@ -76,8 +61,25 @@ class PageSettingsSection
|
||||
->preload()
|
||||
->helperText(__('pages.parent_helper_text'))
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('sort_order')
|
||||
|
||||
Select::make('template')
|
||||
->label(__('pages.template_field'))
|
||||
->options([
|
||||
'default' => __('pages.template_default'),
|
||||
'landing' => __('pages.template_landing'),
|
||||
'blog' => __('pages.template_blog'),
|
||||
'contact' => __('pages.template_contact'),
|
||||
'home' => 'Home',
|
||||
'corporate.testimonials' => 'Müşteri Görüşleri (Kurumsal)',
|
||||
'corporate.logos' => 'Logolarımız (Kurumsal)',
|
||||
'corporate.partners' => 'Çözüm Ortaklarımız (Kurumsal)',
|
||||
'corporate.bank-accounts' => 'Banka Bilgilerimiz (Kurumsal)',
|
||||
'corporate.online-payment' => 'Online Ödeme (Kurumsal)',
|
||||
])
|
||||
->default('default')
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('sort_order')
|
||||
->label(__('pages.sort_order_field'))
|
||||
->numeric()
|
||||
->default(0)
|
||||
|
||||
@@ -18,12 +18,6 @@ class PartnersSection
|
||||
Repeater::make('data.partners')
|
||||
->label('Partnerler')
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->label('Partner Adı')
|
||||
->placeholder('Örn: Microsoft')
|
||||
->maxLength(120)
|
||||
->columnSpanFull(),
|
||||
|
||||
FileUpload::make('logo')
|
||||
->label('Logo')
|
||||
->image()
|
||||
@@ -32,14 +26,13 @@ class PartnersSection
|
||||
->visibility('public')
|
||||
->required()
|
||||
->columnSpanFull(),
|
||||
|
||||
|
||||
TextInput::make('url')
|
||||
->label('Website URL')
|
||||
->url()
|
||||
->placeholder('https://example.com')
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->itemLabel(fn (array $state): ?string => $state['name'] ?? null)
|
||||
->grid(4)
|
||||
->columnSpanFull()
|
||||
->reorderableWithButtons()
|
||||
|
||||
@@ -15,20 +15,12 @@ class SeoSection
|
||||
TextInput::make('meta_title')
|
||||
->label(__('pages.meta_title_field'))
|
||||
->maxLength(60)
|
||||
->extraInputAttributes(['maxlength' => 60])
|
||||
->live(debounce: 200)
|
||||
->hint(fn ($state) => mb_strlen((string) $state) . ' / 60')
|
||||
->hintColor(fn ($state) => mb_strlen((string) $state) > 60 ? 'danger' : 'gray')
|
||||
->helperText(__('pages.meta_title_helper_text')),
|
||||
|
||||
Textarea::make('meta_description')
|
||||
->label(__('pages.meta_description_field'))
|
||||
->rows(3)
|
||||
->maxLength(160)
|
||||
->extraInputAttributes(['maxlength' => 160])
|
||||
->live(debounce: 200)
|
||||
->hint(fn ($state) => mb_strlen((string) $state) . ' / 160')
|
||||
->hintColor(fn ($state) => mb_strlen((string) $state) > 160 ? 'danger' : 'gray')
|
||||
->helperText(__('pages.meta_description_helper_text'))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
|
||||
@@ -96,120 +96,7 @@ class LandingPageSection
|
||||
])
|
||||
->columns(2)
|
||||
->collapsible()
|
||||
->collapsed(),
|
||||
|
||||
// App Features Section
|
||||
Section::make(__('products.app_features_section'))
|
||||
->schema([
|
||||
Repeater::make('landing_page_data.features')
|
||||
->label(__('products.features'))
|
||||
->schema([
|
||||
TextInput::make('icon')
|
||||
->label(__('products.feature_icon')),
|
||||
TextInput::make('title')
|
||||
->label(__('products.feature_title'))
|
||||
->columnSpanFull(),
|
||||
Textarea::make('description')
|
||||
->label(__('products.feature_description'))
|
||||
->rows(2)
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(1)
|
||||
->defaultItems(0)
|
||||
->addActionLabel(__('products.add_feature'))
|
||||
->reorderable()
|
||||
->collapsible()
|
||||
->itemLabel(fn (array $state): ?string => $state['title'] ?? __('products.feature') . ' #' . ($state['_index'] ?? ''))
|
||||
->columnSpanFull(),
|
||||
|
||||
// Note: Translations should match the order of features above
|
||||
// Each feature translation array index should correspond to the feature index
|
||||
])
|
||||
->collapsible()
|
||||
->collapsed(),
|
||||
|
||||
// How It Works Section
|
||||
Section::make(__('products.how_it_works_section'))
|
||||
->schema([
|
||||
FileUpload::make('landing_page_data.how_it_works.download_image')
|
||||
->label(__('products.how_it_works_image'))
|
||||
->image()
|
||||
->disk('public')
|
||||
->directory('products/landing')
|
||||
->columnSpanFull(),
|
||||
TextInput::make('landing_page_data.how_it_works.download_form_label')
|
||||
->label(__('products.download_form_label'))
|
||||
->placeholder(__('products.download_form_label_placeholder'))
|
||||
->columnSpanFull(),
|
||||
|
||||
// Steps (4 steps with translations)
|
||||
Repeater::make('landing_page_data.how_it_works.steps')
|
||||
->label(__('products.steps'))
|
||||
->schema([
|
||||
TextInput::make('number')
|
||||
->label(__('products.step_number'))
|
||||
->numeric()
|
||||
->default(fn ($get) => ($get('../../../_index') ?? 0) + 1),
|
||||
TextInput::make('title')
|
||||
->label(__('products.step_title'))
|
||||
->columnSpanFull(),
|
||||
Textarea::make('description')
|
||||
->label(__('products.step_description'))
|
||||
->rows(2)
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(1)
|
||||
->defaultItems(0)
|
||||
->reorderable()
|
||||
->collapsible()
|
||||
->itemLabel(fn (array $state): ?string => __('products.step') . ' ' . ($state['number'] ?? '') . ': ' . ($state['title'] ?? ''))
|
||||
->columnSpanFull(),
|
||||
|
||||
// Note: Steps translations should match the order of steps above
|
||||
// Each step translation array index should correspond to the step index
|
||||
])
|
||||
->collapsible()
|
||||
->collapsed(),
|
||||
|
||||
// Video Section
|
||||
Section::make(__('products.video_section'))
|
||||
->schema([
|
||||
TextInput::make('landing_page_data.video.youtube_video_id')
|
||||
->label(__('products.youtube_video_id'))
|
||||
->placeholder('165101721')
|
||||
->helperText(__('products.youtube_video_id_helper'))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->collapsible()
|
||||
->collapsed(),
|
||||
|
||||
// FAQ Section
|
||||
Section::make(__('products.faq_section'))
|
||||
->schema([
|
||||
Repeater::make('landing_page_data.faqs')
|
||||
->label(__('products.faqs'))
|
||||
->schema([
|
||||
TextInput::make('question')
|
||||
->label(__('products.faq_question'))
|
||||
->columnSpanFull(),
|
||||
Textarea::make('answer')
|
||||
->label(__('products.faq_answer'))
|
||||
->rows(3)
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(1)
|
||||
->defaultItems(0)
|
||||
->addActionLabel(__('products.add_faq'))
|
||||
->reorderable()
|
||||
->collapsible()
|
||||
->itemLabel(fn (array $state): ?string => $state['question'] ?? __('products.faq') . ' #' . ($state['_index'] ?? ''))
|
||||
->columnSpanFull(),
|
||||
|
||||
// Note: FAQ translations should match the order of FAQs above
|
||||
// Each FAQ translation array index should correspond to the FAQ index
|
||||
])
|
||||
->collapsible()
|
||||
->collapsed(),
|
||||
->collapsed(false),
|
||||
])
|
||||
->visible(fn (Get $get) => $get('type') === 'product')
|
||||
->columnSpanFull();
|
||||
|
||||
@@ -25,6 +25,7 @@ class ProductForm
|
||||
->schema([
|
||||
TextInput::make('title')
|
||||
->label(__('products.title'))
|
||||
->required()
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(function (Get $get, Set $set, ?string $state) {
|
||||
$set('slug', Str::slug($state));
|
||||
@@ -34,13 +35,15 @@ class ProductForm
|
||||
->dehydrated(false),
|
||||
TextInput::make('slug')
|
||||
->label(__('products.slug'))
|
||||
->required()
|
||||
->unique(ignoreRecord: true),
|
||||
Select::make('type')
|
||||
->label(__('products.type'))
|
||||
->options([
|
||||
'product' => __('products.product'),
|
||||
'service' => __('products.service'),
|
||||
]),
|
||||
])
|
||||
->required(),
|
||||
Select::make('product_category_id')
|
||||
->label(__('products.category'))
|
||||
->options(function () {
|
||||
@@ -51,6 +54,7 @@ class ProductForm
|
||||
->searchable(),
|
||||
FileUpload::make('hero_image')
|
||||
->label(__('products.hero_image'))
|
||||
->required()
|
||||
->image()
|
||||
->disk('public')
|
||||
->directory('products'),
|
||||
@@ -77,7 +81,7 @@ class ProductForm
|
||||
'title' => [
|
||||
'type' => 'text',
|
||||
'label' => __('products.title'),
|
||||
'required' => false,
|
||||
'required' => true,
|
||||
],
|
||||
'content' => [
|
||||
'type' => 'richtext',
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\Projects\Pages;
|
||||
|
||||
use App\Filament\Admin\Resources\Projects\ProjectResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Filament\Support\Enums\Width;
|
||||
|
||||
class CreateProject extends CreateRecord
|
||||
{
|
||||
protected static string $resource = ProjectResource::class;
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'Yeni Proje Oluştur';
|
||||
}
|
||||
|
||||
public function getMaxContentWidth(): Width | string | null
|
||||
{
|
||||
return Width::Full;
|
||||
}
|
||||
|
||||
protected function afterCreate(): void
|
||||
{
|
||||
$this->record->recalculateProgress();
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\Projects\Pages;
|
||||
|
||||
use App\Filament\Admin\Resources\Projects\ProjectResource;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Filament\Support\Enums\Width;
|
||||
|
||||
class EditProject extends EditRecord
|
||||
{
|
||||
protected static string $resource = ProjectResource::class;
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'Proje Yönetimi: ' . $this->record->title;
|
||||
}
|
||||
|
||||
public function getMaxContentWidth(): Width | string | null
|
||||
{
|
||||
return Width::Full;
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('recalculate')
|
||||
->label('İlerlemeyi Yeniden Hesapla')
|
||||
->icon('heroicon-o-calculator')
|
||||
->color('info')
|
||||
->action(function () {
|
||||
$pct = $this->record->recalculateProgress();
|
||||
Notification::make()
|
||||
->title('Proje ilerleme yüzdesi güncellendi: %' . $pct)
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
|
||||
Action::make('preview_portal')
|
||||
->label('Müşteri Ekranında Gör')
|
||||
->icon('heroicon-o-eye')
|
||||
->color('warning')
|
||||
->url(fn () => route('projects.show', $this->record->slug))
|
||||
->openUrlInNewTab(),
|
||||
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function afterSave(): void
|
||||
{
|
||||
$this->record->recalculateProgress();
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\Projects\Pages;
|
||||
|
||||
use App\Filament\Admin\Resources\Projects\ProjectResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListProjects extends ListRecords
|
||||
{
|
||||
protected static string $resource = ProjectResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make()
|
||||
->label('Yeni Proje Başlat'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\Projects;
|
||||
|
||||
use App\Filament\Admin\Resources\Projects\Pages\CreateProject;
|
||||
use App\Filament\Admin\Resources\Projects\Pages\EditProject;
|
||||
use App\Filament\Admin\Resources\Projects\Pages\ListProjects;
|
||||
use App\Filament\Admin\Resources\Projects\Schemas\ProjectForm;
|
||||
use App\Filament\Admin\Resources\Projects\Tables\ProjectsTable;
|
||||
use App\Models\Project;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
|
||||
class ProjectResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Project::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-rectangle-stack';
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return 'Proje Takip';
|
||||
}
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return 'Proje';
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return 'Proje Yönetimi';
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return ProjectForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return ProjectsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListProjects::route('/'),
|
||||
'create' => CreateProject::route('/create'),
|
||||
'edit' => EditProject::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getRecordRouteBindingEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getRecordRouteBindingEloquentQuery()
|
||||
->withoutGlobalScopes([
|
||||
SoftDeletingScope::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\Projects\Schemas;
|
||||
|
||||
use Filament\Forms\Components\Checkbox;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ProjectForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Tabs::make('ProjectTabs')
|
||||
->tabs([
|
||||
Tab::make('Proje Detayları & Müşteri')
|
||||
->icon('heroicon-m-briefcase')
|
||||
->schema([
|
||||
Section::make('Temel Proje Bilgileri')
|
||||
->columns(12)
|
||||
->schema([
|
||||
TextInput::make('title')
|
||||
->label('Proje Adı')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(function (string $operation, $state, callable $set) {
|
||||
if ($operation !== 'create') return;
|
||||
$set('slug', Str::slug($state));
|
||||
})
|
||||
->columnSpan(6),
|
||||
|
||||
TextInput::make('slug')
|
||||
->label('URL Slug')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->unique(ignoreRecord: true)
|
||||
->rules(['alpha_dash'])
|
||||
->columnSpan(6),
|
||||
|
||||
TextInput::make('client_name')
|
||||
->label('Müşteri Unvanı / Firma')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->columnSpan(4),
|
||||
|
||||
TextInput::make('client_email')
|
||||
->label('Müşteri E-Posta')
|
||||
->email()
|
||||
->maxLength(255)
|
||||
->columnSpan(4),
|
||||
|
||||
TextInput::make('client_access_code')
|
||||
->label('Müşteri Giriş Şifresi / PIN')
|
||||
->default(fn () => strtoupper(Str::random(6)))
|
||||
->helperText('Müşterinin takip ekranına giriş yapabileceği şifre')
|
||||
->columnSpan(4),
|
||||
|
||||
Select::make('proposal_id')
|
||||
->label('İlişkili Fiyat Teklifi')
|
||||
->relationship('proposal', 'title')
|
||||
->searchable()
|
||||
->preload()
|
||||
->nullable()
|
||||
->live()
|
||||
->afterStateUpdated(function ($state, callable $set) {
|
||||
if ($state) {
|
||||
$prop = \App\Models\Proposal::find($state);
|
||||
if ($prop) {
|
||||
$set('slug', $prop->slug);
|
||||
}
|
||||
}
|
||||
})
|
||||
->columnSpan(4),
|
||||
|
||||
Select::make('status')
|
||||
->label('Proje Genel Durumu')
|
||||
->options([
|
||||
'planning' => 'Planlama Aşamasında',
|
||||
'in_progress' => 'Devam Ediyor (Aktif)',
|
||||
'on_hold' => 'Beklemeye Alındı',
|
||||
'completed' => 'Tamamlandı',
|
||||
'cancelled' => 'İptal Edildi',
|
||||
])
|
||||
->default('in_progress')
|
||||
->required()
|
||||
->columnSpan(4),
|
||||
|
||||
TextInput::make('progress_percent')
|
||||
->label('İlerleme Yüzdesi (%)')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->suffix('%')
|
||||
->columnSpan(4),
|
||||
|
||||
DatePicker::make('start_date')
|
||||
->label('Proje Başlangıç Tarihi')
|
||||
->native(false)
|
||||
->displayFormat('d.m.Y')
|
||||
->columnSpan(6),
|
||||
|
||||
DatePicker::make('target_date')
|
||||
->label('Hedef Bitiş Tarihi')
|
||||
->native(false)
|
||||
->displayFormat('d.m.Y')
|
||||
->columnSpan(6),
|
||||
|
||||
Textarea::make('notes')
|
||||
->label('İç Notlar ve Açıklamalar')
|
||||
->rows(3)
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]),
|
||||
|
||||
Tab::make('Hizmet Modülleri (% İlerleme)')
|
||||
->icon('heroicon-m-squares-2x2')
|
||||
->schema([
|
||||
Section::make('Sözleşme Kapsamındaki Modüller')
|
||||
->description('Hangi modüllerin tamamlandığını işaretleyin. İlerleme yüzdesi modül ağırlıklarına göre otomatik hesaplanır.')
|
||||
->schema([
|
||||
Repeater::make('modules')
|
||||
->relationship('modules')
|
||||
->columns(12)
|
||||
->orderColumn('order')
|
||||
->defaultItems(0)
|
||||
->schema([
|
||||
TextInput::make('title')
|
||||
->label('Modül Adı')
|
||||
->required()
|
||||
->columnSpan(4),
|
||||
|
||||
TextInput::make('weight_percent')
|
||||
->label('Ağırlık (%)')
|
||||
->numeric()
|
||||
->default(10)
|
||||
->suffix('%')
|
||||
->columnSpan(2),
|
||||
|
||||
Select::make('status')
|
||||
->label('Durum')
|
||||
->options([
|
||||
'pending' => 'Bekliyor',
|
||||
'in_progress' => 'Devam Ediyor',
|
||||
'completed' => 'Tamamlandı',
|
||||
])
|
||||
->default('pending')
|
||||
->required()
|
||||
->columnSpan(3),
|
||||
|
||||
DatePicker::make('start_date')
|
||||
->label('Başlangıç')
|
||||
->native(false)
|
||||
->columnSpan(1.5),
|
||||
|
||||
DatePicker::make('end_date')
|
||||
->label('Bitiş')
|
||||
->native(false)
|
||||
->columnSpan(1.5),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]),
|
||||
|
||||
Tab::make('Kanban Görev Kartları')
|
||||
->icon('heroicon-m-view-columns')
|
||||
->schema([
|
||||
Section::make('İş Takvimi ve Görev Listesi')
|
||||
->description('Sözleşmedeki taskları ve detaylı görev kartlarını burada yönetin.')
|
||||
->schema([
|
||||
Repeater::make('tasks')
|
||||
->relationship('tasks')
|
||||
->columns(12)
|
||||
->orderColumn('order_index')
|
||||
->defaultItems(0)
|
||||
->schema([
|
||||
TextInput::make('title')
|
||||
->label('Görev Başlığı')
|
||||
->required()
|
||||
->columnSpan(4),
|
||||
|
||||
Select::make('status')
|
||||
->label('Kanban Durumu')
|
||||
->options([
|
||||
'todo' => 'Yapılacak',
|
||||
'in_progress' => 'Devam Ediyor',
|
||||
'review' => 'Kontrol Bekliyor',
|
||||
'done' => 'Tamamlandı',
|
||||
])
|
||||
->default('todo')
|
||||
->required()
|
||||
->columnSpan(3),
|
||||
|
||||
Select::make('priority')
|
||||
->label('Öncelik')
|
||||
->options([
|
||||
'low' => 'Düşük',
|
||||
'medium' => 'Orta',
|
||||
'high' => 'Yüksek',
|
||||
'urgent' => 'Acil',
|
||||
])
|
||||
->default('medium')
|
||||
->columnSpan(2),
|
||||
|
||||
TextInput::make('assigned_person')
|
||||
->label('Sorumlu')
|
||||
->columnSpan(3),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]),
|
||||
|
||||
Tab::make('Gidişat Güncellemeleri Logu')
|
||||
->icon('heroicon-m-chat-bubble-bottom-center-text')
|
||||
->schema([
|
||||
Section::make('Canlı İlerleme Duyuruları')
|
||||
->description('Yazılım ekibi tarafından girilen ve müşterinin canlı izleyebildiği durum mesajları.')
|
||||
->schema([
|
||||
Repeater::make('updates')
|
||||
->relationship('updates')
|
||||
->columns(12)
|
||||
->defaultItems(0)
|
||||
->schema([
|
||||
TextInput::make('title')
|
||||
->label('Güncelleme Başlığı')
|
||||
->required()
|
||||
->columnSpan(6),
|
||||
|
||||
TextInput::make('progress_percent_at_update')
|
||||
->label('O Anki Yüzde (%)')
|
||||
->numeric()
|
||||
->columnSpan(3),
|
||||
|
||||
Checkbox::make('is_public')
|
||||
->label('Müşteriye Göster')
|
||||
->default(true)
|
||||
->columnSpan(3),
|
||||
|
||||
Textarea::make('content')
|
||||
->label('Güncelleme Notu / Sürüm Açıklaması')
|
||||
->required()
|
||||
->rows(2)
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]),
|
||||
])->columnSpanFull()
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\Projects\Tables;
|
||||
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ProjectsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('title')
|
||||
->label('Proje Adı')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->limit(40),
|
||||
|
||||
TextColumn::make('client_name')
|
||||
->label('Müşteri')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->limit(30),
|
||||
|
||||
TextColumn::make('progress_percent')
|
||||
->label('İlerleme')
|
||||
->formatStateUsing(fn ($state) => "%{$state}")
|
||||
->badge()
|
||||
->color(fn ($state) => match (true) {
|
||||
$state >= 100 => 'success',
|
||||
$state >= 50 => 'info',
|
||||
$state >= 25 => 'warning',
|
||||
default => 'danger',
|
||||
})
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('status')
|
||||
->label('Durum')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'planning' => 'gray',
|
||||
'in_progress' => 'info',
|
||||
'on_hold' => 'warning',
|
||||
'completed' => 'success',
|
||||
'cancelled' => 'danger',
|
||||
default => 'gray',
|
||||
})
|
||||
->formatStateUsing(fn (string $state): string => match ($state) {
|
||||
'planning' => 'Planlama',
|
||||
'in_progress' => 'Devam Ediyor',
|
||||
'on_hold' => 'Beklemede',
|
||||
'completed' => 'Tamamlandı',
|
||||
'cancelled' => 'İptal',
|
||||
default => $state,
|
||||
}),
|
||||
|
||||
TextColumn::make('client_access_code')
|
||||
->label('Müşteri PIN')
|
||||
->copyable()
|
||||
->badge()
|
||||
->color('gray'),
|
||||
|
||||
TextColumn::make('target_date')
|
||||
->label('Hedef Bitiş')
|
||||
->date('d.m.Y')
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('created_at')
|
||||
->label('Oluşturulma')
|
||||
->dateTime('d.m.Y H:i')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('status')
|
||||
->label('Durum')
|
||||
->options([
|
||||
'planning' => 'Planlama',
|
||||
'in_progress' => 'Devam Ediyor',
|
||||
'on_hold' => 'Beklemede',
|
||||
'completed' => 'Tamamlandı',
|
||||
'cancelled' => 'İptal',
|
||||
]),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make()
|
||||
->label('Düzenle'),
|
||||
])
|
||||
->actions([
|
||||
Action::make('view_client_portal')
|
||||
->label('Müşteri Ekranı')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->url(fn ($record) => route('projects.show', $record->slug))
|
||||
->openUrlInNewTab(),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make()
|
||||
->label('Sil'),
|
||||
]),
|
||||
])
|
||||
->defaultSort('created_at', 'desc');
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\Proposals\Pages;
|
||||
|
||||
use App\Filament\Admin\Resources\Proposals\ProposalResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Filament\Support\Enums\Width;
|
||||
|
||||
class CreateProposal extends CreateRecord
|
||||
{
|
||||
protected static string $resource = ProposalResource::class;
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return __('proposal.create');
|
||||
}
|
||||
|
||||
public function getMaxContentWidth(): Width | string | null
|
||||
{
|
||||
return Width::Full;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\Proposals\Pages;
|
||||
|
||||
use App\Filament\Admin\Resources\Proposals\ProposalResource;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Filament\Support\Enums\Width;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class EditProposal extends EditRecord
|
||||
{
|
||||
protected static string $resource = ProposalResource::class;
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return __('proposal.edit');
|
||||
}
|
||||
|
||||
public function getMaxContentWidth(): Width | string | null
|
||||
{
|
||||
return Width::Full;
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
// Teklif linkini tarayıcı panosuna kopyala
|
||||
Action::make('copy_link')
|
||||
->label(__('proposal.copy_link'))
|
||||
->icon('heroicon-o-clipboard-document-check')
|
||||
->color('info')
|
||||
->action(function () {
|
||||
$url = route('proposals.show', $this->record->slug);
|
||||
|
||||
// Livewire v3: sunucu tarafında JS enjekte et (user-gesture gerektirmez)
|
||||
$escaped = addslashes($url);
|
||||
$this->js("(function(){var t=document.createElement('textarea');t.value='{$escaped}';t.style.position='fixed';t.style.opacity='0';document.body.appendChild(t);t.focus();t.select();try{document.execCommand('copy')}catch(e){}document.body.removeChild(t);})();");
|
||||
|
||||
Notification::make()
|
||||
->title(__('proposal.link_copied'))
|
||||
->body($url)
|
||||
->success()
|
||||
->duration(6000)
|
||||
->send();
|
||||
})
|
||||
->visible(fn () => $this->record !== null),
|
||||
|
||||
// Yeni rastgele benzersiz slug üret ve kaydet
|
||||
Action::make('regenerate_slug')
|
||||
->label(__('proposal.regenerate_slug'))
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(__('proposal.regenerate_slug_confirm_heading'))
|
||||
->modalDescription(__('proposal.regenerate_slug_confirm_body'))
|
||||
->modalSubmitActionLabel(__('proposal.regenerate_slug_confirm_button'))
|
||||
->action(function () {
|
||||
$newSlug = 'teklif-' . strtolower(Str::random(4)) . '-' . date('ymd') . '-' . strtolower(Str::random(4));
|
||||
|
||||
// Benzersizliği garanti et
|
||||
while (\App\Models\Proposal::where('slug', $newSlug)->where('id', '!=', $this->record->id)->exists()) {
|
||||
$newSlug = 'teklif-' . strtolower(Str::random(4)) . '-' . date('ymd') . '-' . strtolower(Str::random(4));
|
||||
}
|
||||
|
||||
$this->record->update(['slug' => $newSlug]);
|
||||
|
||||
$newUrl = route('proposals.show', $newSlug);
|
||||
|
||||
Notification::make()
|
||||
->title(__('proposal.regenerate_slug_success'))
|
||||
->body($newUrl)
|
||||
->success()
|
||||
->duration(8000)
|
||||
->send();
|
||||
|
||||
// Formu yenile
|
||||
$this->fillForm();
|
||||
})
|
||||
->visible(fn () => $this->record !== null),
|
||||
|
||||
// Teklifi yeni sekmede önizle
|
||||
Action::make('preview')
|
||||
->label(__('proposal.preview'))
|
||||
->icon('heroicon-o-eye')
|
||||
->color('gray')
|
||||
->url(fn () => $this->record ? route('proposals.show', $this->record->slug) : '#')
|
||||
->openUrlInNewTab()
|
||||
->visible(fn () => $this->record !== null),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\Proposals\Pages;
|
||||
|
||||
use App\Filament\Admin\Resources\Proposals\ProposalResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListProposals extends ListRecords
|
||||
{
|
||||
protected static string $resource = ProposalResource::class;
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return __('proposal.title');
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make()
|
||||
->label(__('proposal.create')),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\Proposals;
|
||||
|
||||
use App\Filament\Admin\Resources\Proposals\Pages\CreateProposal;
|
||||
use App\Filament\Admin\Resources\Proposals\Pages\EditProposal;
|
||||
use App\Filament\Admin\Resources\Proposals\Pages\ListProposals;
|
||||
use App\Filament\Admin\Resources\Proposals\Schemas\ProposalForm;
|
||||
use App\Filament\Admin\Resources\Proposals\Tables\ProposalsTable;
|
||||
use App\Models\Proposal;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
|
||||
class ProposalResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Proposal::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-text';
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('proposal.navigation_label');
|
||||
}
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('proposal.model_label');
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return __('proposal.plural_model_label');
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return ProposalForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return ProposalsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListProposals::route('/'),
|
||||
'create' => CreateProposal::route('/create'),
|
||||
'edit' => EditProposal::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getRecordRouteBindingEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getRecordRouteBindingEloquentQuery()
|
||||
->withoutGlobalScopes([
|
||||
SoftDeletingScope::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\Proposals\Schemas;
|
||||
|
||||
use Filament\Forms\Components\Checkbox;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\CodeEditor;
|
||||
use Filament\Forms\Components\CodeEditor\Enums\Language as CodeEditorLanguage;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\View;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class ProposalForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Tabs::make('Tabs')
|
||||
->tabs([
|
||||
Tab::make('Teklif Detayları ve İçerik')
|
||||
->icon('heroicon-m-document-text')
|
||||
->schema([
|
||||
// Section 2: side-by-side Markdown editor (CodeMirror) + live preview
|
||||
Section::make(__('proposal.content_section'))
|
||||
->description('Sol tarafta Markdown formatında içeriği düzenleyin, sağ tarafta canlı olarak nasıl görüneceğini izleyin.')
|
||||
->columns(2)
|
||||
->schema([
|
||||
CodeEditor::make('content')
|
||||
->label(__('proposal.content_field'))
|
||||
->hiddenLabel()
|
||||
->required()
|
||||
->language(CodeEditorLanguage::Markdown)
|
||||
->live(onBlur: false)
|
||||
->columnSpan(1)
|
||||
->extraAttributes([
|
||||
'style' => 'min-height: 700px; max-height: 700px; overflow-y: auto;',
|
||||
'data-field-name' => 'content',
|
||||
]),
|
||||
|
||||
View::make('filament.components.markdown-preview')
|
||||
->columnSpan(1),
|
||||
]),
|
||||
]),
|
||||
Tab::make('Durum ve Ayarlar')
|
||||
->icon('heroicon-m-cog-6-tooth')
|
||||
->schema([
|
||||
// Section 1: compact top metadata and configuration
|
||||
Section::make(__('proposal.settings_section'))
|
||||
->columns(12)
|
||||
->schema([
|
||||
TextInput::make('title')
|
||||
->label(__('proposal.title_field'))
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(function (string $operation, $state, callable $set) {
|
||||
if ($operation !== 'create') {
|
||||
return;
|
||||
}
|
||||
$set('slug', \Str::slug($state));
|
||||
})
|
||||
->columnSpan(4),
|
||||
|
||||
TextInput::make('slug')
|
||||
->label(__('proposal.slug_field'))
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->unique(ignoreRecord: true)
|
||||
->rules(['alpha_dash'])
|
||||
->helperText(__('proposal.slug_helper'))
|
||||
->columnSpan(4),
|
||||
|
||||
Select::make('status')
|
||||
->label(__('proposal.status_field'))
|
||||
->options([
|
||||
'draft' => __('proposal.status_draft'),
|
||||
'sent' => __('proposal.status_sent'),
|
||||
'accepted' => __('proposal.status_accepted'),
|
||||
'rejected' => __('proposal.status_rejected'),
|
||||
'revised' => __('proposal.status_revised'),
|
||||
])
|
||||
->default('draft')
|
||||
->required()
|
||||
->columnSpan(4),
|
||||
|
||||
TextInput::make('client_name')
|
||||
->label(__('proposal.client_name_field'))
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->columnSpan(4),
|
||||
|
||||
TextInput::make('client_email')
|
||||
->label(__('proposal.client_email_field'))
|
||||
->email()
|
||||
->maxLength(255)
|
||||
->columnSpan(4),
|
||||
|
||||
DatePicker::make('valid_until')
|
||||
->label(__('proposal.valid_until_field'))
|
||||
->native(false)
|
||||
->displayFormat('d.m.Y')
|
||||
->columnSpan(4),
|
||||
|
||||
TextInput::make('total_price')
|
||||
->label(__('proposal.total_price_field'))
|
||||
->numeric()
|
||||
->prefix(fn ($get) => match ($get('currency')) {
|
||||
'USD' => '$',
|
||||
'EUR' => '€',
|
||||
default => '₺',
|
||||
})
|
||||
->columnSpan(3),
|
||||
|
||||
Select::make('currency')
|
||||
->label(__('proposal.currency_field'))
|
||||
->options([
|
||||
'TRY' => 'TL (₺)',
|
||||
'USD' => 'USD ($)',
|
||||
'EUR' => 'EUR (€)',
|
||||
])
|
||||
->default('TRY')
|
||||
->required()
|
||||
->live()
|
||||
->columnSpan(1),
|
||||
|
||||
Select::make('meta.accent_color')
|
||||
->label(__('proposal.accent_color_field'))
|
||||
->options([
|
||||
'indigo' => 'Premium Indigo (Mor/Mavi)',
|
||||
'emerald' => 'Emerald Medical (Zümrüt Yeşil)',
|
||||
'cyberpunk' => 'Cyberpunk Neon (Pembe/Mavi)',
|
||||
'coral' => 'Coral Corporate (Mercan Kırmızı)',
|
||||
'amber' => 'Amber Executive (Kehribar/Altın)',
|
||||
])
|
||||
->default('coral')
|
||||
->live()
|
||||
->columnSpan(4),
|
||||
|
||||
Checkbox::make('meta.show_calculator')
|
||||
->label(__('proposal.show_calculator_field'))
|
||||
->default(true)
|
||||
->columnSpan(4),
|
||||
|
||||
// Client feedback inside this section — only visible when present
|
||||
Textarea::make('client_feedback')
|
||||
->label('Müşteri Geri Bildirimi / Revizyon Notu')
|
||||
->rows(3)
|
||||
->disabled()
|
||||
->dehydrated(false)
|
||||
->placeholder('Henüz geri bildirim bırakılmadı.')
|
||||
->visible(fn ($record) => $record !== null && !empty($record->client_feedback))
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]),
|
||||
])->columnSpanFull()
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\Proposals\Tables;
|
||||
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ProposalsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('title')
|
||||
->label(__('proposal.table_title'))
|
||||
->searchable()
|
||||
->sortable()
|
||||
->limit(40),
|
||||
|
||||
TextColumn::make('client_name')
|
||||
->label(__('proposal.table_client'))
|
||||
->searchable()
|
||||
->sortable()
|
||||
->limit(30),
|
||||
|
||||
TextColumn::make('total_price')
|
||||
->label(__('proposal.table_price'))
|
||||
->money(fn ($record) => $record->currency)
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('status')
|
||||
->label(__('proposal.table_status'))
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'draft' => 'gray',
|
||||
'sent' => 'info',
|
||||
'accepted' => 'success',
|
||||
'rejected' => 'danger',
|
||||
'revised' => 'warning',
|
||||
default => 'gray',
|
||||
})
|
||||
->formatStateUsing(fn (string $state): string => match ($state) {
|
||||
'draft' => __('proposal.status_draft'),
|
||||
'sent' => __('proposal.status_sent'),
|
||||
'accepted' => __('proposal.status_accepted'),
|
||||
'rejected' => __('proposal.status_rejected'),
|
||||
'revised' => __('proposal.status_revised'),
|
||||
default => $state,
|
||||
}),
|
||||
|
||||
TextColumn::make('slug')
|
||||
->label(__('proposal.table_slug'))
|
||||
->searchable()
|
||||
->copyable()
|
||||
->copyMessage(__('proposal.link_copied'))
|
||||
->icon('heroicon-o-document-duplicate')
|
||||
->limit(25),
|
||||
|
||||
TextColumn::make('valid_until')
|
||||
->label(__('proposal.table_valid_until'))
|
||||
->date('d.m.Y')
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('views_count')
|
||||
->label(__('proposal.table_views'))
|
||||
->sortable()
|
||||
->alignCenter(),
|
||||
|
||||
TextColumn::make('created_at')
|
||||
->label(__('proposal.table_created_at'))
|
||||
->dateTime('d.m.Y H:i')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('status')
|
||||
->label(__('proposal.status_field'))
|
||||
->options([
|
||||
'draft' => __('proposal.status_draft'),
|
||||
'sent' => __('proposal.status_sent'),
|
||||
'accepted' => __('proposal.status_accepted'),
|
||||
'rejected' => __('proposal.status_rejected'),
|
||||
'revised' => __('proposal.status_revised'),
|
||||
]),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make()
|
||||
->label(__('proposal.edit')),
|
||||
])
|
||||
->actions([
|
||||
Action::make('view_proposal')
|
||||
->label(__('proposal.preview'))
|
||||
->icon('heroicon-o-eye')
|
||||
->url(fn ($record) => route('proposals.show', $record->slug))
|
||||
->openUrlInNewTab(),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make()
|
||||
->label(__('proposal.delete')),
|
||||
]),
|
||||
])
|
||||
->defaultSort('created_at', 'desc');
|
||||
}
|
||||
}
|
||||
@@ -24,12 +24,7 @@ class EditSetting extends EditRecord
|
||||
|
||||
if (isset($data['type'])) {
|
||||
$type = $data['type'];
|
||||
|
||||
// Map the setting type to the form field name
|
||||
$virtualField = match ($type) {
|
||||
'string', 'text', 'json', 'integer', 'float' => 'value_text',
|
||||
default => 'value_' . $type,
|
||||
};
|
||||
$virtualField = 'value_' . $type;
|
||||
|
||||
try {
|
||||
// Model accessor'ını kullanarak değeri al
|
||||
|
||||
@@ -115,7 +115,7 @@ class SettingForm
|
||||
->label(__('settings.value'))
|
||||
->helperText(__('settings.value_helper'))
|
||||
->required()
|
||||
->visible(fn (Get $get) => in_array($get('type'), ['string', 'text', 'json', 'integer', 'float']) && !in_array($get('key'), ['default_header', 'default_footer']))
|
||||
->visible(fn (Get $get) => in_array($get('type'), ['string', 'text', 'json']) && !in_array($get('key'), ['default_header', 'default_footer']))
|
||||
->rows(fn (Get $get) => $get('type') === 'text' ? 5 : 3)
|
||||
->columnSpanFull(),
|
||||
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
use Carbon\Carbon;
|
||||
|
||||
class TurkeyHolidayHelper
|
||||
{
|
||||
/**
|
||||
* Checks if a given date is a national or religious holiday in Turkey.
|
||||
*
|
||||
* @param string|Carbon $date
|
||||
* @return bool
|
||||
*/
|
||||
public static function isHoliday($date): bool
|
||||
{
|
||||
$carbonDate = Carbon::parse($date);
|
||||
$md = $carbonDate->format('m-d'); // Month-Day
|
||||
$ymd = $carbonDate->format('Y-m-d'); // Year-Month-Day
|
||||
|
||||
// 1. Fixed Yearly Holidays
|
||||
$fixedHolidays = [
|
||||
'01-01', // Yılbaşı (New Year's Day)
|
||||
'04-23', // Ulusal Egemenlik ve Çocuk Bayramı
|
||||
'05-01', // Emek ve Dayanışma Günü
|
||||
'05-19', // Atatürk'ü Anma, Gençlik ve Spor Bayramı
|
||||
'07-15', // Demokrasi ve Milli Birlik Günü
|
||||
'08-30', // Zafer Bayramı
|
||||
'10-29', // Cumhuriyet Bayramı
|
||||
];
|
||||
|
||||
if (in_array($md, $fixedHolidays)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. Variable Holidays (Ramazan & Kurban Bayramı including Eve/Arife days)
|
||||
// Hardcoded ranges for 2024 to 2030 based on Islamic lunar calendar projections.
|
||||
$variableHolidays = [
|
||||
// 2024
|
||||
'2024-04-09', // Ramazan Bayramı Arife
|
||||
'2024-04-10', '2024-04-11', '2024-04-12', // Ramazan Bayramı
|
||||
'2024-06-15', // Kurban Bayramı Arife
|
||||
'2024-06-16', '2024-06-17', '2024-06-18', '2024-06-19', // Kurban Bayramı
|
||||
|
||||
// 2025
|
||||
'2025-03-29', // Ramazan Bayramı Arife
|
||||
'2025-03-30', '2025-03-31', '2025-04-01', // Ramazan Bayramı
|
||||
'2025-06-05', // Kurban Bayramı Arife
|
||||
'2025-06-06', '2025-06-07', '2025-06-08', '2025-06-09', // Kurban Bayramı
|
||||
|
||||
// 2026
|
||||
'2026-03-19', // Ramazan Bayramı Arife
|
||||
'2026-03-20', '2026-03-21', '2026-03-22', // Ramazan Bayramı
|
||||
'2026-05-26', // Kurban Bayramı Arife
|
||||
'2026-05-27', '2026-05-28', '2026-05-29', '2026-05-30', // Kurban Bayramı
|
||||
|
||||
// 2027
|
||||
'2027-03-08', // Ramazan Bayramı Arife
|
||||
'2027-03-09', '2027-03-10', '2027-03-11', // Ramazan Bayramı
|
||||
'2027-05-15', // Kurban Bayramı Arife
|
||||
'2027-05-16', '2027-05-17', '2027-05-18', '2027-05-19', // Kurban Bayramı
|
||||
|
||||
// 2028
|
||||
'2028-02-26', // Ramazan Bayramı Arife
|
||||
'2028-02-27', '2028-02-28', '2028-02-29', // Ramazan Bayramı
|
||||
'2028-05-04', // Kurban Bayramı Arife
|
||||
'2028-05-05', '2028-05-06', '2028-05-07', '2028-05-08', // Kurban Bayramı
|
||||
|
||||
// 2029
|
||||
'2029-02-14', // Ramazan Bayramı Arife
|
||||
'2029-02-15', '2029-02-16', '2029-02-17', // Ramazan Bayramı
|
||||
'2029-04-23', // Kurban Bayramı Arife (Note: also April 23)
|
||||
'2029-04-24', '2029-04-25', '2029-04-26', '2029-04-27', // Kurban Bayramı
|
||||
|
||||
// 2030
|
||||
'2030-02-03', // Ramazan Bayramı Arife
|
||||
'2030-02-04', '2030-02-05', '2030-02-06', // Ramazan Bayramı
|
||||
'2030-04-12', // Kurban Bayramı Arife
|
||||
'2030-04-13', '2030-04-14', '2030-04-15', '2030-04-16', // Kurban Bayramı
|
||||
];
|
||||
|
||||
return in_array($ymd, $variableHolidays);
|
||||
}
|
||||
}
|
||||
@@ -222,93 +222,6 @@ if (!function_exists('get_translation_status_color')) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('flag_emoji_from_country_code')) {
|
||||
/**
|
||||
* Build a flag emoji from a two-letter country/region code (e.g. TR → 🇹🇷).
|
||||
*/
|
||||
function flag_emoji_from_country_code(string $countryCode): string
|
||||
{
|
||||
$countryCode = strtoupper(substr($countryCode, 0, 2));
|
||||
$flag = '';
|
||||
|
||||
for ($i = 0; $i < strlen($countryCode); $i++) {
|
||||
$flag .= mb_chr(127397 + ord($countryCode[$i]));
|
||||
}
|
||||
|
||||
return $flag;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('country_code_from_flag_emoji')) {
|
||||
/**
|
||||
* Extract a two-letter code from a flag emoji (e.g. 🇬🇧 → GB).
|
||||
*/
|
||||
function country_code_from_flag_emoji(?string $flag): ?string
|
||||
{
|
||||
if (empty($flag)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$code = '';
|
||||
|
||||
foreach (mb_str_split($flag) as $char) {
|
||||
$ord = mb_ord($char);
|
||||
|
||||
if ($ord >= 0x1F1E6 && $ord <= 0x1F1FF) {
|
||||
$code .= chr(ord('A') + ($ord - 0x1F1E6));
|
||||
}
|
||||
}
|
||||
|
||||
return strlen($code) === 2 ? $code : null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('resolve_language_flag')) {
|
||||
/**
|
||||
* Resolve flag emoji for a language (stored flag or generated from code).
|
||||
*/
|
||||
function resolve_language_flag(Language|string $language): string
|
||||
{
|
||||
if (is_string($language)) {
|
||||
$language = Language::findByCode($language);
|
||||
}
|
||||
|
||||
if (! $language) {
|
||||
return '🌐';
|
||||
}
|
||||
|
||||
if (! empty($language->flag) && $language->flag !== '🌐') {
|
||||
return $language->flag;
|
||||
}
|
||||
|
||||
return flag_emoji_from_country_code($language->code);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('get_language_short_code')) {
|
||||
/**
|
||||
* Short display code for language (from flag region, e.g. GB, TR, SE).
|
||||
*/
|
||||
function get_language_short_code(Language|string $language): string
|
||||
{
|
||||
if (is_string($language)) {
|
||||
$model = Language::findByCode($language);
|
||||
|
||||
return $model ? get_language_short_code($model) : strtoupper($language);
|
||||
}
|
||||
|
||||
$fromFlag = country_code_from_flag_emoji($language->flag);
|
||||
|
||||
if ($fromFlag) {
|
||||
return $fromFlag;
|
||||
}
|
||||
|
||||
$fromResolved = country_code_from_flag_emoji(resolve_language_flag($language));
|
||||
|
||||
return $fromResolved ?: strtoupper($language->code);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('get_language_flag')) {
|
||||
/**
|
||||
* Get language flag emoji or icon
|
||||
@@ -316,8 +229,7 @@ if (!function_exists('get_language_flag')) {
|
||||
function get_language_flag(string $languageCode): string
|
||||
{
|
||||
$language = Language::findByCode($languageCode);
|
||||
|
||||
return $language ? resolve_language_flag($language) : '🌐';
|
||||
return $language ? ($language->flag ?? '🌐') : '🌐';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,26 +296,3 @@ if (!function_exists('t')) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('locale_upper')) {
|
||||
/**
|
||||
* Locale-aware uppercase (Turkish: i→İ, ı→I, etc.)
|
||||
*/
|
||||
function locale_upper(string $text, ?string $locale = null): string
|
||||
{
|
||||
$locale = $locale ?? app()->getLocale();
|
||||
|
||||
if (str_starts_with($locale, 'tr')) {
|
||||
return mb_strtoupper(
|
||||
str_replace(
|
||||
['i', 'ı', 'ğ', 'ü', 'ş', 'ö', 'ç'],
|
||||
['İ', 'I', 'Ğ', 'Ü', 'Ş', 'Ö', 'Ç'],
|
||||
$text
|
||||
),
|
||||
'UTF-8'
|
||||
);
|
||||
}
|
||||
|
||||
return mb_strtoupper($text, 'UTF-8');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\LinkedInService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class LinkedInController extends Controller
|
||||
{
|
||||
protected LinkedInService $linkedInService;
|
||||
|
||||
public function __construct(LinkedInService $linkedInService)
|
||||
{
|
||||
$this->linkedInService = $linkedInService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect admin to LinkedIn OAuth consent screen
|
||||
*/
|
||||
public function connect(Request $request)
|
||||
{
|
||||
$url = $this->linkedInService->getAuthorizationUrl();
|
||||
return redirect()->away($url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OAuth Callback from LinkedIn
|
||||
*/
|
||||
public function callback(Request $request)
|
||||
{
|
||||
if ($request->has('error')) {
|
||||
$errorDescription = $request->input('error_description', 'Yetkilendirme iptal edildi.');
|
||||
return redirect('/admin/linked-in-settings')
|
||||
->with('error', 'LinkedIn bağlantı hatası: ' . $errorDescription);
|
||||
}
|
||||
|
||||
$code = $request->input('code');
|
||||
if (!$code) {
|
||||
return redirect('/admin/linked-in-settings')
|
||||
->with('error', 'LinkedIn yetkilendirme kodu (code) alınamadı.');
|
||||
}
|
||||
|
||||
$result = $this->linkedInService->handleCallback($code);
|
||||
|
||||
if ($result['success']) {
|
||||
return redirect('/admin/linked-in-settings')
|
||||
->with('success', $result['message']);
|
||||
}
|
||||
|
||||
return redirect('/admin/linked-in-settings')
|
||||
->with('error', $result['message']);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ use App\Models\HeaderTemplate;
|
||||
use App\Models\FooterTemplate;
|
||||
use App\Services\TemplateService;
|
||||
use App\Models\Page;
|
||||
use App\Support\BlogStructuredData;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
@@ -20,7 +19,7 @@ class BlogController extends Controller
|
||||
$settings = class_exists(Setting::class) ? (Setting::query()->first()) : null;
|
||||
|
||||
$query = class_exists(Blog::class)
|
||||
? Blog::with(['category', 'author', 'careerApplication'])
|
||||
? Blog::with(['category', 'author'])
|
||||
->withCount('comments')
|
||||
->published()
|
||||
: null;
|
||||
@@ -29,9 +28,6 @@ class BlogController extends Controller
|
||||
if ($query && $request->has('author') && $request->author) {
|
||||
$query = $query->where('author_id', $request->author);
|
||||
}
|
||||
if ($query && $request->has('intern') && $request->intern) {
|
||||
$query = $query->where('career_application_id', $request->intern);
|
||||
}
|
||||
|
||||
// Category filtresi
|
||||
if ($query && $request->has('category') && $request->category) {
|
||||
@@ -45,16 +41,6 @@ class BlogController extends Controller
|
||||
$query = $query->whereJsonContains('tags', $request->tag);
|
||||
}
|
||||
|
||||
// Search filtresi
|
||||
if ($query && $request->has('search') && $request->search) {
|
||||
$search = $request->search;
|
||||
$query = $query->where(function($q) use ($search) {
|
||||
$q->where('title', 'like', '%' . $search . '%')
|
||||
->orWhere('content', 'like', '%' . $search . '%')
|
||||
->orWhere('excerpt', 'like', '%' . $search . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$posts = $query
|
||||
? $query->latest('published_at')->paginate(12)
|
||||
: collect();
|
||||
@@ -123,101 +109,22 @@ class BlogController extends Controller
|
||||
// Kategorileri al (filtreleme için)
|
||||
$categories = class_exists(\App\Models\BlogCategory::class) ? \App\Models\BlogCategory::where('is_active', true)->orderBy('sort_order')->get() : collect();
|
||||
|
||||
// Elemis Widget'ları için veri hazırlığı
|
||||
$carouselPosts = collect();
|
||||
$highlightCategories = collect();
|
||||
$popularPosts = collect();
|
||||
$sidebarCategories = collect();
|
||||
$tags = collect();
|
||||
|
||||
if (!$request->ajax()) {
|
||||
if (class_exists(Blog::class)) {
|
||||
// Karusel: Öne çıkarılan ya da en güncel 5 yazı
|
||||
$carouselPosts = Blog::with(['category', 'author', 'careerApplication'])
|
||||
->published()
|
||||
->featured()
|
||||
->latest('published_at')
|
||||
->take(5)
|
||||
->get();
|
||||
if ($carouselPosts->isEmpty()) {
|
||||
$carouselPosts = Blog::with(['category', 'author', 'careerApplication'])
|
||||
->published()
|
||||
->latest('published_at')
|
||||
->take(5)
|
||||
->get();
|
||||
}
|
||||
|
||||
// Popüler Yazılar: En çok okunan 3 yazı
|
||||
$popularPosts = Blog::with(['category', 'author', 'careerApplication'])
|
||||
->published()
|
||||
->orderBy('view_count', 'desc')
|
||||
->take(3)
|
||||
->get();
|
||||
|
||||
// Etiket Bulutu: Tüm yayınlanan yazılardaki benzersiz etiketler
|
||||
$allTags = Blog::published()
|
||||
->whereNotNull('tags')
|
||||
->pluck('tags');
|
||||
$tagsCollected = collect();
|
||||
foreach ($allTags as $postTags) {
|
||||
if (is_array($postTags)) {
|
||||
foreach ($postTags as $tag) {
|
||||
$tagsCollected->push($tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
$tags = $tagsCollected->unique()->take(15);
|
||||
}
|
||||
|
||||
if (class_exists(\App\Models\BlogCategory::class)) {
|
||||
// Hoş Geldiniz bölümü altındaki 4'lü kategori vurgusu
|
||||
$highlightCategories = \App\Models\BlogCategory::where('is_active', true)
|
||||
->withCount('blogs')
|
||||
->has('blogs')
|
||||
->orderBy('sort_order')
|
||||
->take(4)
|
||||
->get();
|
||||
if ($highlightCategories->count() < 4) {
|
||||
$highlightCategories = \App\Models\BlogCategory::where('is_active', true)
|
||||
->orderBy('sort_order')
|
||||
->take(4)
|
||||
->get();
|
||||
}
|
||||
|
||||
// Sidebar Kategorileri: Yazı sayısıyla birlikte
|
||||
$sidebarCategories = \App\Models\BlogCategory::where('is_active', true)
|
||||
->withCount('blogs')
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->ajax()) {
|
||||
return view('blog.partials.posts', [
|
||||
'posts' => $posts
|
||||
]);
|
||||
}
|
||||
|
||||
$pageUrl = url()->current();
|
||||
|
||||
return view('blog.index', [
|
||||
'settings' => $settings,
|
||||
'posts' => $posts,
|
||||
'categories' => $categories,
|
||||
'carouselPosts' => $carouselPosts,
|
||||
'highlightCategories' => $highlightCategories,
|
||||
'popularPosts' => $popularPosts,
|
||||
'sidebarCategories' => $sidebarCategories,
|
||||
'tags' => $tags,
|
||||
'renderedHeader' => $renderedHeader,
|
||||
'renderedFooter' => $renderedFooter,
|
||||
'meta' => [
|
||||
'title' => __('blog.meta-index-title'),
|
||||
'description' => __('blog.meta-index-description'),
|
||||
],
|
||||
'structuredData' => $posts instanceof \Illuminate\Contracts\Pagination\LengthAwarePaginator
|
||||
? BlogStructuredData::forIndex($posts, $pageUrl)
|
||||
: null,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -227,14 +134,14 @@ class BlogController extends Controller
|
||||
if (!class_exists(Blog::class)) {
|
||||
abort(404);
|
||||
}
|
||||
$post = Blog::with(['category', 'author', 'careerApplication'])
|
||||
$post = Blog::with(['category', 'author'])
|
||||
->withCount('comments')
|
||||
->published()
|
||||
->where('slug', $slug)
|
||||
->firstOrFail();
|
||||
|
||||
// İlgili blog gönderilerini al (aynı kategoriden, mevcut gönderi hariç)
|
||||
$relatedPosts = Blog::with(['category', 'author', 'careerApplication'])
|
||||
$relatedPosts = Blog::with(['category', 'author'])
|
||||
->withCount('comments')
|
||||
->published()
|
||||
->where('id', '!=', $post->id)
|
||||
@@ -247,7 +154,7 @@ class BlogController extends Controller
|
||||
|
||||
// Eğer aynı kategoriden yeterli gönderi yoksa, diğer kategorilerden ekle
|
||||
if ($relatedPosts->count() < 4) {
|
||||
$additionalPosts = Blog::with(['category', 'author', 'careerApplication'])
|
||||
$additionalPosts = Blog::with(['category', 'author'])
|
||||
->withCount('comments')
|
||||
->published()
|
||||
->where('id', '!=', $post->id)
|
||||
@@ -308,8 +215,6 @@ class BlogController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
$pageUrl = route('blog.show', $post->slug);
|
||||
|
||||
return view('blog.show', [
|
||||
'settings' => $settings,
|
||||
'post' => $post,
|
||||
@@ -321,9 +226,7 @@ class BlogController extends Controller
|
||||
'title' => method_exists($post, 'translate') ? ($post->translate('meta_title') ?: $post->translate('title')) : ($post->meta_title ?? $post->title),
|
||||
'description' => method_exists($post, 'translate') ? ($post->translate('meta_description') ?: $post->translate('excerpt')) : ($post->meta_description ?? $post->excerpt),
|
||||
'image' => $post->featured_image ? asset('storage/' . $post->featured_image) : null,
|
||||
'og_type' => 'article',
|
||||
],
|
||||
'structuredData' => BlogStructuredData::forShow($post, $pageUrl),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\CareerApplication;
|
||||
use App\Models\Blog;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CareerController extends Controller
|
||||
{
|
||||
@@ -43,7 +41,6 @@ class CareerController extends Controller
|
||||
if ($request->type === 'job') {
|
||||
$rules['nda'] = 'required|file|mimes:pdf|max:5120';
|
||||
$rules['contract'] = 'required|file|mimes:pdf|max:5120';
|
||||
$rules['id_photocopy'] = 'required|file|mimes:pdf,jpg,jpeg,png|max:5120'; // Max 5MB
|
||||
$rules['git_knowledge'] = 'accepted';
|
||||
$rules['ai_usage'] = 'accepted';
|
||||
}
|
||||
@@ -53,7 +50,6 @@ class CareerController extends Controller
|
||||
$cvPath = $request->file('cv') ? $request->file('cv')->store('cvs', 'public') : null;
|
||||
$ndaPath = $request->file('nda') ? $request->file('nda')->store('ndas', 'public') : null;
|
||||
$contractPath = $request->file('contract') ? $request->file('contract')->store('contracts', 'public') : null;
|
||||
$idPhotocopyPath = $request->file('id_photocopy') ? $request->file('id_photocopy')->store('id_photocopies', 'public') : null;
|
||||
|
||||
CareerApplication::create([
|
||||
'name' => $request->name,
|
||||
@@ -63,7 +59,6 @@ class CareerController extends Controller
|
||||
'cv_path' => $cvPath,
|
||||
'nda_path' => $ndaPath,
|
||||
'contract_path' => $contractPath,
|
||||
'id_photocopy_path' => $idPhotocopyPath,
|
||||
'git_knowledge' => $request->has('git_knowledge'),
|
||||
'ai_usage' => $request->has('ai_usage'),
|
||||
'message' => $request->message,
|
||||
@@ -79,840 +74,4 @@ class CareerController extends Controller
|
||||
|
||||
return redirect()->back()->with('success', __('career.success_message'));
|
||||
}
|
||||
|
||||
public function internLoginForm()
|
||||
{
|
||||
if (session()->has('intern_id')) {
|
||||
return redirect()->route('intern.dashboard');
|
||||
}
|
||||
return view('front.career.intern_login', [
|
||||
'meta' => [
|
||||
'title' => 'Stajyer Girişi',
|
||||
'description' => 'İmzalı staj belgelerinize erişmek için lütfen giriş yapın.',
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
public function internLogin(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'username' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
]);
|
||||
|
||||
$intern = CareerApplication::where('type', 'internship')
|
||||
->where(function ($q) use ($request) {
|
||||
$q->where('username', $request->username)
|
||||
->orWhere('email', $request->username);
|
||||
})
|
||||
->first();
|
||||
|
||||
if (!$intern || !\Illuminate\Support\Facades\Hash::check($request->password, $intern->password)) {
|
||||
return redirect()->back()
|
||||
->withInput($request->only('username'))
|
||||
->withErrors([
|
||||
'username' => 'Girdiğiniz kullanıcı adı veya şifre hatalı.',
|
||||
]);
|
||||
}
|
||||
|
||||
session(['intern_id' => $intern->id]);
|
||||
|
||||
return redirect()->route('intern.dashboard')->with('success', 'Başarıyla giriş yapıldı.');
|
||||
}
|
||||
|
||||
public function internDashboard()
|
||||
{
|
||||
if (!session()->has('intern_id')) {
|
||||
return redirect()->route('intern.login')->with('error', 'Lütfen giriş yapın.');
|
||||
}
|
||||
|
||||
$intern = CareerApplication::findOrFail(session('intern_id'));
|
||||
$days = self::getInternshipDates($intern->internship_start_date, $intern->internship_total_days);
|
||||
$savedEntries = $intern->journalEntries()->get()->keyBy('day_number');
|
||||
$blogs = $intern->blogs()->orderBy('created_at', 'desc')->get();
|
||||
|
||||
return view('front.career.intern_dashboard', [
|
||||
'intern' => $intern,
|
||||
'days' => $days,
|
||||
'savedEntries' => $savedEntries,
|
||||
'blogs' => $blogs,
|
||||
'meta' => [
|
||||
'title' => 'Stajyer Paneli',
|
||||
'description' => 'Staj belgelerinizi ve blog yazılarınızı yönetin.',
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveInternBlog(Request $request)
|
||||
{
|
||||
if (!session()->has('intern_id')) {
|
||||
return redirect()->route('intern.login')->with('error', 'Lütfen önce giriş yapın.');
|
||||
}
|
||||
|
||||
$intern = CareerApplication::findOrFail(session('intern_id'));
|
||||
|
||||
$request->validate([
|
||||
'blog_id' => 'nullable|integer|exists:blogs,id',
|
||||
'title' => 'required|string|max:255',
|
||||
'intern_category' => 'required|string|in:experience,technical_challenge,product_showcase',
|
||||
'excerpt' => 'nullable|string|max:500',
|
||||
'meta_title' => 'nullable|string|max:60',
|
||||
'meta_description' => 'nullable|string|max:160',
|
||||
'content' => 'required|string|min:50',
|
||||
'featured_image' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:5120',
|
||||
'action_type' => 'required|string|in:draft,submit',
|
||||
], [
|
||||
'title.required' => 'Lütfen blog yazısı başlığını girin.',
|
||||
'intern_category.required' => 'Lütfen bir kategori seçin.',
|
||||
'content.required' => 'Lütfen blog yazısı içeriğini girin.',
|
||||
'content.min' => 'Blog içeriği en az 50 karakter olmalıdır.',
|
||||
'meta_title.max' => 'Meta Başlık en fazla 60 karakter olmalıdır.',
|
||||
'meta_description.max' => 'Meta Açıklama en fazla 160 karakter olmalıdır.',
|
||||
'featured_image.image' => 'Görsel geçerli bir resim dosyası olmalıdır.',
|
||||
]);
|
||||
|
||||
$status = $request->action_type === 'draft' ? 'draft' : 'pending';
|
||||
|
||||
if ($request->filled('blog_id')) {
|
||||
$blog = Blog::where('id', $request->blog_id)
|
||||
->where('career_application_id', $intern->id)
|
||||
->firstOrFail();
|
||||
} else {
|
||||
$blog = new Blog();
|
||||
$blog->career_application_id = $intern->id;
|
||||
}
|
||||
|
||||
// Handle slug
|
||||
if (!$blog->exists || $blog->title !== $request->title) {
|
||||
$baseSlug = Str::slug($request->title);
|
||||
$slug = $baseSlug;
|
||||
$count = 1;
|
||||
while (Blog::where('slug', $slug)->where('id', '!=', $blog->id ?? 0)->exists()) {
|
||||
$slug = $baseSlug . '-' . $count;
|
||||
$count++;
|
||||
}
|
||||
$blog->slug = $slug;
|
||||
}
|
||||
|
||||
$blog->title = $request->title;
|
||||
$blog->intern_category = $request->intern_category;
|
||||
$blog->excerpt = $request->excerpt;
|
||||
$blog->content = $request->content;
|
||||
$blog->status = $status;
|
||||
$blog->meta_title = $request->filled('meta_title') ? Str::limit($request->meta_title, 60, '') : Str::limit($request->title, 60, '');
|
||||
$blog->meta_description = $request->filled('meta_description') ? Str::limit($request->meta_description, 160, '') : Str::limit(strip_tags($request->excerpt ?: $request->content), 160, '');
|
||||
|
||||
if ($request->hasFile('featured_image')) {
|
||||
if ($blog->featured_image && Storage::disk('public')->exists($blog->featured_image)) {
|
||||
Storage::disk('public')->delete($blog->featured_image);
|
||||
}
|
||||
$blog->featured_image = $request->file('featured_image')->store('blogs', 'public');
|
||||
}
|
||||
|
||||
$blog->save();
|
||||
|
||||
$msg = $status === 'draft' ? 'Blog yazısı taslak olarak kaydedildi.' : 'Blog yazısı incelemeye gönderildi.';
|
||||
return redirect()->back()->with('success', $msg);
|
||||
}
|
||||
|
||||
public function deleteInternBlog($id)
|
||||
{
|
||||
if (!session()->has('intern_id')) {
|
||||
return redirect()->route('intern.login')->with('error', 'Lütfen giriş yapın.');
|
||||
}
|
||||
|
||||
$blog = Blog::where('id', $id)
|
||||
->where('career_application_id', session('intern_id'))
|
||||
->firstOrFail();
|
||||
|
||||
if (in_array($blog->status, ['draft', 'pending', 'rejected'])) {
|
||||
if ($blog->featured_image && Storage::disk('public')->exists($blog->featured_image)) {
|
||||
Storage::disk('public')->delete($blog->featured_image);
|
||||
}
|
||||
$blog->forceDelete();
|
||||
return redirect()->back()->with('success', 'Blog yazısı silindi.');
|
||||
}
|
||||
|
||||
return redirect()->back()->with('error', 'Yayınlanmış blog yazıları silinemez.');
|
||||
}
|
||||
|
||||
public function uploadInternshipForm(Request $request)
|
||||
{
|
||||
if (!session()->has('intern_id')) {
|
||||
return redirect()->route('intern.login')->with('error', 'Lütfen önce giriş yapın.');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'internship_form' => 'required|file|mimes:pdf,docx,jpg,png,jpeg|max:51200',
|
||||
'internship_start_date' => 'required|date',
|
||||
'internship_total_days' => 'required|integer|min:1',
|
||||
], [
|
||||
'internship_form.required' => 'Lütfen bir dosya seçin.',
|
||||
'internship_form.file' => 'Yüklenen öğe geçerli bir dosya olmalıdır.',
|
||||
'internship_form.mimes' => 'Yalnızca PDF, DOCX, JPG ve PNG formatındaki dosyalar kabul edilir.',
|
||||
'internship_form.max' => 'Dosya boyutu en fazla 50MB olabilir.',
|
||||
'internship_start_date.required' => 'Lütfen staj başlangıç tarihini girin.',
|
||||
'internship_start_date.date' => 'Geçerli bir başlangıç tarihi girin.',
|
||||
'internship_total_days.required' => 'Lütfen staj süresini girin.',
|
||||
'internship_total_days.integer' => 'Staj süresi geçerli bir tam sayı olmalıdır.',
|
||||
'internship_total_days.min' => 'Staj süresi en az 1 gün olmalıdır.',
|
||||
]);
|
||||
|
||||
$intern = CareerApplication::findOrFail(session('intern_id'));
|
||||
|
||||
if ($request->hasFile('internship_form')) {
|
||||
// Delete old file if exists
|
||||
if ($intern->to_be_signed_internship_form_path) {
|
||||
Storage::disk('public')->delete($intern->to_be_signed_internship_form_path);
|
||||
}
|
||||
|
||||
$path = $request->file('internship_form')->store('to_be_signed_interns', 'public');
|
||||
|
||||
// Calculate end date based on weekdays and duration
|
||||
$startDate = \Carbon\Carbon::parse($request->internship_start_date);
|
||||
$daysToAdd = intval($request->internship_total_days);
|
||||
|
||||
$endDate = $startDate->copy();
|
||||
$count = 0;
|
||||
$temp = $startDate->copy();
|
||||
|
||||
while ($count < $daysToAdd) {
|
||||
if ($temp->isWeekend() || \App\Helpers\TurkeyHolidayHelper::isHoliday($temp)) {
|
||||
$temp->addDay();
|
||||
continue;
|
||||
}
|
||||
$endDate = $temp->copy();
|
||||
$temp->addDay();
|
||||
$count++;
|
||||
}
|
||||
|
||||
$intern->update([
|
||||
'to_be_signed_internship_form_path' => $path,
|
||||
'internship_start_date' => $request->internship_start_date,
|
||||
'internship_end_date' => $endDate->format('Y-m-d'),
|
||||
'internship_total_days' => $daysToAdd,
|
||||
'status' => 'waiting_document',
|
||||
]);
|
||||
|
||||
return redirect()->back()->with('success', 'İmzalanacak staj formunuz ve staj tarihleri başarıyla kaydedildi.');
|
||||
}
|
||||
|
||||
return redirect()->back()->with('error', 'Dosya yüklenirken bir hata oluştu.');
|
||||
}
|
||||
|
||||
public function saveGithubRepo(Request $request)
|
||||
{
|
||||
if (!session()->has('intern_id')) {
|
||||
return redirect()->route('intern.login')->with('error', 'Lütfen önce giriş yapın.');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'github_repo' => 'required|string|url|max:255',
|
||||
'github_username' => 'nullable|string|max:100',
|
||||
], [
|
||||
'github_repo.required' => 'Lütfen Github depo URL\'sini girin.',
|
||||
'github_repo.url' => 'Geçerli bir URL girilmelidir.',
|
||||
'github_repo.max' => 'Github depo URL\'si en fazla 255 karakter olabilir.',
|
||||
'github_username.max' => 'Github kullanıcı adı en fazla 100 karakter olabilir.',
|
||||
]);
|
||||
|
||||
$intern = CareerApplication::findOrFail(session('intern_id'));
|
||||
|
||||
// Sanitize & format github URL
|
||||
$repoUrl = trim($request->github_repo);
|
||||
$username = trim($request->github_username);
|
||||
|
||||
$intern->update([
|
||||
'github_repo' => $repoUrl,
|
||||
'github_username' => $username ?: null,
|
||||
]);
|
||||
|
||||
if ($request->expectsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Github bilgileri başarıyla güncellendi.',
|
||||
'github_repo' => $repoUrl,
|
||||
'github_username' => $username ?: null,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', 'Github bilgileri başarıyla güncellendi.');
|
||||
}
|
||||
|
||||
public function downloadMarkdown()
|
||||
{
|
||||
if (request()->has('intern_id') && auth()->check() && auth()->user()->hasRole('super_admin')) {
|
||||
$internId = request('intern_id');
|
||||
} else {
|
||||
if (!session()->has('intern_id')) {
|
||||
return redirect()->route('intern.login')->with('error', 'Lütfen önce giriş yapın.');
|
||||
}
|
||||
$internId = session('intern_id');
|
||||
}
|
||||
|
||||
$intern = CareerApplication::findOrFail($internId);
|
||||
$repo = $intern->github_repo;
|
||||
|
||||
if (!$repo) {
|
||||
return redirect()->back()->with('error', 'Lütfen önce Github deposunu tanımlayın.');
|
||||
}
|
||||
|
||||
// Parse owner and repo
|
||||
// E.g., https://github.com/owner/repo or owner/repo
|
||||
$repoClean = str_replace(['https://github.com/', 'http://github.com/', 'https://www.github.com/', 'http://www.github.com/'], '', $repo);
|
||||
$repoClean = trim($repoClean, '/');
|
||||
$parts = explode('/', $repoClean);
|
||||
if (count($parts) < 2) {
|
||||
return redirect()->back()->with('error', 'Geçersiz Github depo formatı.');
|
||||
}
|
||||
$owner = $parts[0];
|
||||
$repoName = explode('.', $parts[1])[0]; // Remove .git if exists
|
||||
|
||||
// Fetch commits
|
||||
$url = "https://api.github.com/repos/{$owner}/{$repoName}/commits?per_page=100";
|
||||
|
||||
$opts = [
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'header' => [
|
||||
'User-Agent: PHP-Github-Client',
|
||||
]
|
||||
]
|
||||
];
|
||||
$context = stream_context_create($opts);
|
||||
$response = @file_get_contents($url, false, $context);
|
||||
|
||||
if ($response === false) {
|
||||
return redirect()->back()->with('error', 'Github API\'den commit bilgileri alınamadı. Deponuzun public olduğundan emin olun.');
|
||||
}
|
||||
|
||||
$commits = json_decode($response, true);
|
||||
if (!is_array($commits)) {
|
||||
return redirect()->back()->with('error', 'Github API yanıtı çözümlenemedi.');
|
||||
}
|
||||
|
||||
// Sort commits chronological (ascending)
|
||||
$commits = array_reverse($commits);
|
||||
|
||||
// Group commits by date (YYYY-MM-DD)
|
||||
$grouped = [];
|
||||
foreach ($commits as $c) {
|
||||
$dateStr = $c['commit']['author']['date'] ?? '';
|
||||
if ($dateStr) {
|
||||
$dateOnly = \Carbon\Carbon::parse($dateStr)->format('Y-m-d');
|
||||
$grouped[$dateOnly][] = $c;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort dates chronological (ascending)
|
||||
ksort($grouped);
|
||||
|
||||
// Generate Markdown content
|
||||
$md = "# Staj Günlüğü - " . $intern->name . "\n";
|
||||
$md .= "Depo: https://github.com/{$owner}/{$repoName}\n\n";
|
||||
|
||||
$day = 1;
|
||||
foreach ($grouped as $date => $dayCommits) {
|
||||
$formattedDate = \Carbon\Carbon::parse($date)->format('d.m.Y');
|
||||
$md .= "## {$day}. Gün ({$formattedDate})\n";
|
||||
foreach ($dayCommits as $c) {
|
||||
$message = trim($c['commit']['message'] ?? '');
|
||||
$time = \Carbon\Carbon::parse($c['commit']['author']['date'] ?? '')->format('H:i');
|
||||
$hash = substr($c['sha'] ?? '', 0, 7);
|
||||
$md .= "- [{$time}] `{$hash}`: {$message}\n";
|
||||
}
|
||||
$md .= "\n";
|
||||
$day++;
|
||||
}
|
||||
|
||||
$fileName = str()->slug($intern->name) . "-staj-gunlugu.md";
|
||||
|
||||
return response($md, 200, [
|
||||
'Content-Type' => 'text/markdown; charset=UTF-8',
|
||||
'Content-Disposition' => 'attachment; filename="' . $fileName . '"',
|
||||
]);
|
||||
}
|
||||
|
||||
public function internLogout()
|
||||
{
|
||||
session()->forget('intern_id');
|
||||
return redirect()->route('intern.login')->with('success', 'Başarıyla çıkış yapıldı.');
|
||||
}
|
||||
|
||||
public function verifyCertificate($code)
|
||||
{
|
||||
$application = CareerApplication::where('certificate_code', $code)
|
||||
->where('type', 'internship')
|
||||
->firstOrFail();
|
||||
|
||||
$days = self::getInternshipDates($application->internship_start_date, $application->internship_total_days);
|
||||
$savedEntries = $application->journalEntries()->get()->keyBy('day_number');
|
||||
|
||||
return view('front.career.verify', [
|
||||
'application' => $application,
|
||||
'days' => $days,
|
||||
'savedEntries' => $savedEntries,
|
||||
'meta' => [
|
||||
'title' => 'Staj Bitirme Sertifikası Doğrulama - ' . $application->name,
|
||||
'description' => $application->name . ' isimli stajyerimizin staj bitirme sertifikası ve performans raporu doğrulama sayfası.',
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
public function internAdminLoginForm()
|
||||
{
|
||||
if (auth()->check() && auth()->user()->hasRole('super_admin')) {
|
||||
return redirect()->route('intern.admin.dashboard');
|
||||
}
|
||||
return view('front.career.admin_login', [
|
||||
'meta' => [
|
||||
'title' => 'Yönetici Girişi - Staj Takip',
|
||||
'description' => 'Stajyerleri yönetmek için giriş yapın.',
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
public function internAdminLogin(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'email' => 'required|email',
|
||||
'password' => 'required|string',
|
||||
]);
|
||||
|
||||
if (auth()->attempt($request->only('email', 'password'))) {
|
||||
if (auth()->user()->hasRole('super_admin')) {
|
||||
return redirect()->route('intern.admin.dashboard')->with('success', 'Yönetici girişi başarıyla gerçekleştirildi.');
|
||||
}
|
||||
|
||||
auth()->logout();
|
||||
return redirect()->back()
|
||||
->withInput($request->only('email'))
|
||||
->withErrors([
|
||||
'email' => 'Bu panele erişmek için super_admin yetkinizin olması gerekir.',
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->back()
|
||||
->withInput($request->only('email'))
|
||||
->withErrors([
|
||||
'email' => 'Girdiğiniz bilgiler eşleşmedi veya hatalı.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function internAdminDashboard()
|
||||
{
|
||||
if (!auth()->check() || !auth()->user()->hasRole('super_admin')) {
|
||||
return redirect()->route('intern.admin.login')->with('error', 'Lütfen önce yönetici olarak giriş yapın.');
|
||||
}
|
||||
|
||||
$allInterns = CareerApplication::where('type', 'internship')
|
||||
->with(['journalEntries'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
$ganttInterns = CareerApplication::where('type', 'internship')
|
||||
->where('status', 'accepted')
|
||||
->whereNotNull('internship_start_date')
|
||||
->whereNotNull('internship_end_date')
|
||||
->orderBy('internship_start_date', 'asc')
|
||||
->get()
|
||||
->map(function ($intern) {
|
||||
return [
|
||||
'id' => $intern->id,
|
||||
'parentId' => null,
|
||||
'title' => $intern->name,
|
||||
'start' => $intern->internship_start_date,
|
||||
'end' => $intern->internship_end_date,
|
||||
'progress' => 100
|
||||
];
|
||||
});
|
||||
|
||||
$unapprovedCount = \App\Models\InternshipJournalEntry::where('supervisor_approved', false)
|
||||
->whereNotNull('content')
|
||||
->where('content', '!=', '')
|
||||
->whereHas('careerApplication', function ($q) {
|
||||
$q->where('type', 'internship');
|
||||
})
|
||||
->count();
|
||||
|
||||
return view('front.career.admin_dashboard', [
|
||||
'allInterns' => $allInterns,
|
||||
'ganttInterns' => $ganttInterns,
|
||||
'unapprovedCount' => $unapprovedCount,
|
||||
'meta' => [
|
||||
'title' => 'Staj Yönetici Paneli',
|
||||
'description' => 'Stajyer bilgilerini ve takvimlerini izleyin.',
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
public function internAdminLogout()
|
||||
{
|
||||
auth()->logout();
|
||||
return redirect()->route('intern.admin.login')->with('success', 'Yönetici oturumu sonlandırıldı.');
|
||||
}
|
||||
|
||||
public static function getInternshipDates($startDate, $totalDays)
|
||||
{
|
||||
if (!$startDate || !$totalDays || $totalDays <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$dates = [];
|
||||
$temp = \Carbon\Carbon::parse($startDate);
|
||||
$daysToAdd = intval($totalDays);
|
||||
$count = 0;
|
||||
|
||||
while ($count < $daysToAdd) {
|
||||
if ($temp->isWeekend() || \App\Helpers\TurkeyHolidayHelper::isHoliday($temp)) {
|
||||
$temp->addDay();
|
||||
continue;
|
||||
}
|
||||
$dates[] = [
|
||||
'day_number' => $count + 1,
|
||||
'date' => $temp->format('Y-m-d'),
|
||||
'formatted_date' => $temp->format('d.m.Y'),
|
||||
];
|
||||
$temp->addDay();
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $dates;
|
||||
}
|
||||
|
||||
public function saveJournalEntry(Request $request)
|
||||
{
|
||||
if (!session()->has('intern_id')) {
|
||||
return response()->json(['success' => false, 'message' => 'Lütfen giriş yapın.'], 401);
|
||||
}
|
||||
|
||||
$intern = CareerApplication::findOrFail(session('intern_id'));
|
||||
|
||||
$request->validate([
|
||||
'day_number' => 'required|integer|min:1',
|
||||
'date' => 'required|date',
|
||||
'content' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$dateObj = \Carbon\Carbon::parse($request->date);
|
||||
|
||||
// 1. Prevent future entries
|
||||
if ($dateObj->isFuture()) {
|
||||
return response()->json(['success' => false, 'message' => 'İleriye dönük staj günleri için defter doldurulamaz.'], 422);
|
||||
}
|
||||
|
||||
// 2. Prevent weekend and holiday entries
|
||||
if ($dateObj->isWeekend() || \App\Helpers\TurkeyHolidayHelper::isHoliday($dateObj)) {
|
||||
return response()->json(['success' => false, 'message' => 'Hafta sonu günlerine ve resmi tatillere staj günlüğü girilemez.'], 422);
|
||||
}
|
||||
|
||||
// 3. Verify alignment of day_number and date based on internship calendar
|
||||
$dates = self::getInternshipDates($intern->internship_start_date, $intern->internship_total_days);
|
||||
$dateToDayMap = [];
|
||||
foreach ($dates as $d) {
|
||||
$dateToDayMap[$d['date']] = $d['day_number'];
|
||||
}
|
||||
|
||||
$expectedDay = $dateToDayMap[$request->date] ?? null;
|
||||
if ($expectedDay === null || $expectedDay != $request->day_number) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Seçilen tarih ve gün sırası uyuşmuyor. Lütfen sayfayı yenileyip tekrar deneyin.'
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 4. Determine if retroactive
|
||||
$isRetroactive = $dateObj->lt(\Carbon\Carbon::today());
|
||||
|
||||
$entry = \App\Models\InternshipJournalEntry::updateOrCreate([
|
||||
'career_application_id' => $intern->id,
|
||||
'day_number' => $request->day_number,
|
||||
], [
|
||||
'date' => $request->date,
|
||||
'content' => $request->content,
|
||||
'is_retroactive' => $isRetroactive,
|
||||
'supervisor_approved' => false,
|
||||
'supervisor_name' => null,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $request->day_number . '. Gün kaydı başarıyla kaydedildi.' . ($isRetroactive ? ' (Geriye Dönük Kayıt)' : ''),
|
||||
'entry' => $entry
|
||||
]);
|
||||
}
|
||||
|
||||
public function printJournal(Request $request)
|
||||
{
|
||||
if (request()->has('intern_id') && auth()->check() && auth()->user()->hasRole('super_admin')) {
|
||||
$internId = request('intern_id');
|
||||
} else {
|
||||
if (!session()->has('intern_id')) {
|
||||
return redirect()->route('intern.login')->with('error', 'Lütfen önce giriş yapın.');
|
||||
}
|
||||
$internId = session('intern_id');
|
||||
}
|
||||
|
||||
$intern = CareerApplication::findOrFail($internId);
|
||||
$size = $request->query('size', 'a4');
|
||||
if (!in_array($size, ['a4', 'a5'])) {
|
||||
$size = 'a4';
|
||||
}
|
||||
|
||||
$days = self::getInternshipDates($intern->internship_start_date, $intern->internship_total_days);
|
||||
|
||||
$dayFilter = $request->query('day');
|
||||
if ($dayFilter) {
|
||||
$days = array_filter($days, function($d) use ($dayFilter) {
|
||||
return $d['day_number'] == $dayFilter;
|
||||
});
|
||||
$days = array_values($days);
|
||||
}
|
||||
|
||||
$savedEntries = $intern->journalEntries()->get()->keyBy('day_number');
|
||||
|
||||
return view('front.career.print', [
|
||||
'intern' => $intern,
|
||||
'days' => $days,
|
||||
'savedEntries' => $savedEntries,
|
||||
'size' => $size,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getJournalEntry(Request $request)
|
||||
{
|
||||
if (!auth()->check()) {
|
||||
return response()->json(['success' => false, 'message' => 'Yetkisiz işlem.'], 403);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'intern_id' => 'required|integer|exists:career_applications,id',
|
||||
'date' => 'required|date',
|
||||
]);
|
||||
|
||||
$intern = \App\Models\CareerApplication::findOrFail($request->intern_id);
|
||||
|
||||
$days = self::getInternshipDates($intern->internship_start_date, $intern->internship_total_days);
|
||||
$dayNum = null;
|
||||
$dateFormatted = null;
|
||||
foreach ($days as $d) {
|
||||
if ($d['date'] === $request->date) {
|
||||
$dayNum = $d['day_number'];
|
||||
$dateFormatted = $d['formatted_date'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$dayNum) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Seçilen tarih staj dönemi dışındadır veya haftasonudur.'
|
||||
], 422);
|
||||
}
|
||||
|
||||
$entry = $intern->journalEntries()->where('date', $request->date)->first();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'intern_name' => $intern->name,
|
||||
'day_number' => $dayNum,
|
||||
'date' => $request->date,
|
||||
'date_formatted' => $dateFormatted,
|
||||
'entry' => $entry ? [
|
||||
'id' => $entry->id,
|
||||
'content' => $entry->content,
|
||||
'is_retroactive' => $entry->is_retroactive,
|
||||
'supervisor_approved' => $entry->supervisor_approved,
|
||||
'supervisor_name' => $entry->supervisor_name,
|
||||
] : null
|
||||
]);
|
||||
}
|
||||
|
||||
public function toggleJournalApproval(Request $request)
|
||||
{
|
||||
if (!auth()->check()) {
|
||||
return response()->json(['success' => false, 'message' => 'Yetkisiz işlem.'], 403);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'entry_id' => 'required|integer|exists:internship_journal_entries,id',
|
||||
]);
|
||||
|
||||
$entry = \App\Models\InternshipJournalEntry::findOrFail($request->entry_id);
|
||||
|
||||
$entry->supervisor_approved = !$entry->supervisor_approved;
|
||||
if ($entry->supervisor_approved) {
|
||||
$entry->supervisor_name = auth()->user()->name;
|
||||
} else {
|
||||
$entry->supervisor_name = null;
|
||||
}
|
||||
$entry->save();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'status' => $entry->supervisor_approved,
|
||||
'supervisor_name' => $entry->supervisor_name,
|
||||
'message' => 'Staj sorumlusu onayı güncellendi.'
|
||||
]);
|
||||
}
|
||||
|
||||
public function getInternJournalDetails(Request $request)
|
||||
{
|
||||
if (!auth()->check()) {
|
||||
return response()->json(['success' => false, 'message' => 'Yetkisiz işlem.'], 403);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'intern_id' => 'required|integer|exists:career_applications,id',
|
||||
]);
|
||||
|
||||
$intern = CareerApplication::findOrFail($request->intern_id);
|
||||
|
||||
// Get all days calculated
|
||||
$days = self::getInternshipDates($intern->internship_start_date, $intern->internship_total_days);
|
||||
|
||||
// Get saved journal entries
|
||||
$savedEntries = $intern->journalEntries()->get()->keyBy('day_number');
|
||||
|
||||
// Prepare entries list matched by day number
|
||||
$entries = [];
|
||||
$filledDaysCount = 0;
|
||||
|
||||
foreach ($days as $d) {
|
||||
$dayNum = $d['day_number'];
|
||||
$entry = $savedEntries->get($dayNum);
|
||||
|
||||
$hasSaved = $entry && trim($entry->content) !== '';
|
||||
if ($hasSaved) {
|
||||
$filledDaysCount++;
|
||||
}
|
||||
|
||||
$entries[] = [
|
||||
'day_number' => $dayNum,
|
||||
'date' => $d['date'],
|
||||
'formatted_date' => $d['formatted_date'],
|
||||
'filled' => $hasSaved,
|
||||
'entry_id' => $entry ? $entry->id : null,
|
||||
'content' => $entry ? $entry->content : null,
|
||||
'is_retroactive' => $entry ? $entry->is_retroactive : false,
|
||||
'supervisor_approved' => $entry ? (bool)$entry->supervisor_approved : false,
|
||||
'supervisor_name' => $entry ? $entry->supervisor_name : null,
|
||||
];
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'intern' => [
|
||||
'id' => $intern->id,
|
||||
'name' => $intern->name,
|
||||
'email' => $intern->email,
|
||||
'phone' => $intern->phone,
|
||||
'start_date' => $intern->internship_start_date,
|
||||
'end_date' => $intern->internship_end_date,
|
||||
'total_days' => $intern->internship_total_days,
|
||||
'filled_days' => $filledDaysCount,
|
||||
'certificate_code' => $intern->certificate_code,
|
||||
'github_repo' => $intern->github_repo,
|
||||
'notebook_supervisor_signed' => (bool)$intern->notebook_supervisor_signed,
|
||||
'notebook_supervisor_name' => $intern->notebook_supervisor_name,
|
||||
'notebook_unit_signed' => (bool)$intern->notebook_unit_signed,
|
||||
'notebook_unit_name' => $intern->notebook_unit_name,
|
||||
'notebook_approved' => (bool)$intern->notebook_approved,
|
||||
],
|
||||
'entries' => $entries,
|
||||
]);
|
||||
}
|
||||
|
||||
public function toggleNotebookSignature(Request $request)
|
||||
{
|
||||
if (!auth()->check()) {
|
||||
return response()->json(['success' => false, 'message' => 'Yetkisiz işlem.'], 403);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'intern_id' => 'required|integer|exists:career_applications,id',
|
||||
'type' => 'required|string|in:supervisor,unit,approved',
|
||||
'signed' => 'required|boolean',
|
||||
'name' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$intern = CareerApplication::findOrFail($request->intern_id);
|
||||
|
||||
$type = $request->type;
|
||||
$signed = $request->signed;
|
||||
$name = $request->name ?: auth()->user()->name;
|
||||
|
||||
if ($type === 'supervisor') {
|
||||
$intern->notebook_supervisor_signed = $signed;
|
||||
$intern->notebook_supervisor_name = $signed ? $name : null;
|
||||
} elseif ($type === 'unit') {
|
||||
$intern->notebook_unit_signed = $signed;
|
||||
$intern->notebook_unit_name = $signed ? $name : null;
|
||||
} elseif ($type === 'approved') {
|
||||
$intern->notebook_approved = $signed;
|
||||
}
|
||||
|
||||
$intern->save();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Staj defteri onay durumu güncellendi.',
|
||||
'intern' => [
|
||||
'notebook_supervisor_signed' => (bool)$intern->notebook_supervisor_signed,
|
||||
'notebook_supervisor_name' => $intern->notebook_supervisor_name,
|
||||
'notebook_unit_signed' => (bool)$intern->notebook_unit_signed,
|
||||
'notebook_unit_name' => $intern->notebook_unit_name,
|
||||
'notebook_approved' => (bool)$intern->notebook_approved,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
public function getQuickApprovalEntries(Request $request)
|
||||
{
|
||||
if (!auth()->check() || !auth()->user()->hasRole('super_admin')) {
|
||||
return response()->json(['success' => false, 'message' => 'Yetkisiz işlem.'], 403);
|
||||
}
|
||||
|
||||
$type = $request->query('type', 'unapproved'); // 'unapproved' or 'today'
|
||||
$date = $request->query('date');
|
||||
|
||||
$query = \App\Models\InternshipJournalEntry::with('careerApplication')
|
||||
->whereHas('careerApplication', function ($q) {
|
||||
$q->where('type', 'internship');
|
||||
});
|
||||
|
||||
if ($type === 'today') {
|
||||
$targetDate = $date ?: \Carbon\Carbon::today()->format('Y-m-d');
|
||||
$query->whereDate('date', $targetDate);
|
||||
} elseif ($type === 'unapproved') {
|
||||
$query->where('supervisor_approved', false);
|
||||
}
|
||||
|
||||
// Only return entries that have content
|
||||
$query->whereNotNull('content')->where('content', '!=', '');
|
||||
|
||||
$entries = $query->orderBy('date', 'desc')->get()->map(function ($entry) {
|
||||
return [
|
||||
'id' => $entry->id,
|
||||
'day_number' => $entry->day_number,
|
||||
'date' => $entry->date,
|
||||
'formatted_date' => \Carbon\Carbon::parse($entry->date)->format('d.m.Y'),
|
||||
'content' => $entry->content,
|
||||
'is_retroactive' => (bool)$entry->is_retroactive,
|
||||
'supervisor_approved' => (bool)$entry->supervisor_approved,
|
||||
'supervisor_name' => $entry->supervisor_name,
|
||||
'intern' => [
|
||||
'id' => $entry->careerApplication->id,
|
||||
'name' => $entry->careerApplication->name,
|
||||
'email' => $entry->careerApplication->email,
|
||||
]
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'entries' => $entries
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\MusicProduction;
|
||||
use App\Models\Setting;
|
||||
use App\Models\FooterTemplate;
|
||||
use App\Services\TemplateService;
|
||||
use App\Support\MusicProductionStructuredData;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class MusicProductionController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
// Get Settings
|
||||
$settings = new \stdClass();
|
||||
$allSettings = Setting::query()->where('is_active', true)->get();
|
||||
foreach ($allSettings as $setting) {
|
||||
$settings->{$setting->key} = $setting->value;
|
||||
}
|
||||
|
||||
$search = trim((string) request('q', ''));
|
||||
|
||||
$productionsQuery = MusicProduction::active()->ordered();
|
||||
|
||||
if ($search !== '') {
|
||||
$locale = app()->getLocale();
|
||||
|
||||
$productionsQuery->where(function ($query) use ($search, $locale) {
|
||||
$query->where('title', 'like', "%{$search}%")
|
||||
->orWhere('client_name', 'like', "%{$search}%")
|
||||
->orWhere('slug', 'like', "%{$search}%")
|
||||
->orWhereHas('translations', function ($translationQuery) use ($search, $locale) {
|
||||
$translationQuery
|
||||
->where('status', 'published')
|
||||
->whereIn('field_name', ['title', 'client_name', 'content'])
|
||||
->where('field_value', 'like', "%{$search}%")
|
||||
->where('language_code', $locale);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$productions = $productionsQuery->paginate(9)->withQueryString();
|
||||
|
||||
// --- Footer Logic ---
|
||||
$renderedFooter = null;
|
||||
$defaultFooterId = $settings->default_footer ?? null;
|
||||
|
||||
if ($defaultFooterId) {
|
||||
$footerTemplate = FooterTemplate::query()->find($defaultFooterId);
|
||||
if ($footerTemplate) {
|
||||
$templateDefaults = $footerTemplate->default_data ?? [];
|
||||
$mergedFooterData = array_merge($templateDefaults, []);
|
||||
|
||||
$renderedFooter = TemplateService::replacePlaceholders(
|
||||
$footerTemplate->html_content,
|
||||
$mergedFooterData,
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$canonicalUrl = route('music-productions.index');
|
||||
$pageUrl = $search !== '' ? $canonicalUrl : url()->current();
|
||||
|
||||
$meta = [
|
||||
'title' => __('music_productions.meta-index-title'),
|
||||
'description' => __('music_productions.meta-index-description'),
|
||||
'image' => asset('assets/music-production.png'),
|
||||
'image_alt' => __('music_productions.meta-index-title'),
|
||||
'canonical' => $canonicalUrl,
|
||||
'robots' => $search !== '' ? 'noindex, follow' : 'index, follow',
|
||||
'og_type' => 'website',
|
||||
'locale' => app()->getLocale(),
|
||||
];
|
||||
|
||||
$structuredData = MusicProductionStructuredData::forIndex($productions, $pageUrl);
|
||||
|
||||
return view('front.music-productions.index', [
|
||||
'productions' => $productions,
|
||||
'search' => $search,
|
||||
'settings' => $settings,
|
||||
'header' => 'partials.header-center-nav',
|
||||
'renderedFooter' => $renderedFooter,
|
||||
'meta' => $meta,
|
||||
'structuredData' => $structuredData,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show($slug)
|
||||
{
|
||||
$production = MusicProduction::active()
|
||||
->where('slug', $slug)
|
||||
->firstOrFail();
|
||||
|
||||
// Get Settings
|
||||
$settings = new \stdClass();
|
||||
$allSettings = Setting::query()->where('is_active', true)->get();
|
||||
foreach ($allSettings as $setting) {
|
||||
$settings->{$setting->key} = $setting->value;
|
||||
}
|
||||
|
||||
// Fetch adjacent posts (Prev and Next)
|
||||
$prev = MusicProduction::active()
|
||||
->where(function($q) use ($production) {
|
||||
$q->where('sort_order', '<', $production->sort_order)
|
||||
->orWhere(function($sub) use ($production) {
|
||||
$sub->where('sort_order', $production->sort_order)
|
||||
->where('id', '<', $production->id);
|
||||
});
|
||||
})
|
||||
->orderBy('sort_order', 'desc')
|
||||
->orderBy('id', 'desc')
|
||||
->first();
|
||||
|
||||
$next = MusicProduction::active()
|
||||
->where(function($q) use ($production) {
|
||||
$q->where('sort_order', '>', $production->sort_order)
|
||||
->orWhere(function($sub) use ($production) {
|
||||
$sub->where('sort_order', $production->sort_order)
|
||||
->where('id', '>', $production->id);
|
||||
});
|
||||
})
|
||||
->orderBy('sort_order', 'asc')
|
||||
->orderBy('id', 'asc')
|
||||
->first();
|
||||
|
||||
// --- Footer Logic ---
|
||||
$renderedFooter = null;
|
||||
$defaultFooterId = $settings->default_footer ?? null;
|
||||
|
||||
if ($defaultFooterId) {
|
||||
$footerTemplate = FooterTemplate::query()->find($defaultFooterId);
|
||||
if ($footerTemplate) {
|
||||
$templateDefaults = $footerTemplate->default_data ?? [];
|
||||
$mergedFooterData = array_merge($templateDefaults, []);
|
||||
|
||||
$renderedFooter = TemplateService::replacePlaceholders(
|
||||
$footerTemplate->html_content,
|
||||
$mergedFooterData,
|
||||
$production
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$pageUrl = route('music-productions.show', $production->slug);
|
||||
$title = $production->translate('title');
|
||||
$description = Str::limit(strip_tags((string) $production->translate('content')), 160)
|
||||
?: __('music_productions.meta-index-description');
|
||||
|
||||
$meta = [
|
||||
'title' => $title,
|
||||
'description' => $description,
|
||||
'image' => $production->cover_image_url ?: asset('assets/music-production.png'),
|
||||
'image_alt' => $title,
|
||||
'canonical' => $pageUrl,
|
||||
'robots' => 'index, follow',
|
||||
'og_type' => 'article',
|
||||
'locale' => app()->getLocale(),
|
||||
];
|
||||
|
||||
$structuredData = MusicProductionStructuredData::forShow($production, $pageUrl);
|
||||
|
||||
return view('front.music-productions.show', [
|
||||
'production' => $production,
|
||||
'prev' => $prev,
|
||||
'next' => $next,
|
||||
'settings' => $settings,
|
||||
'header' => 'partials.header-center-nav',
|
||||
'renderedFooter' => $renderedFooter,
|
||||
'meta' => $meta,
|
||||
'structuredData' => $structuredData,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class OemLookupController extends Controller
|
||||
{
|
||||
/**
|
||||
* Live OEM & Barcode Lookup API (Internet Search & Database Fallback)
|
||||
*/
|
||||
public function lookup(Request $request)
|
||||
{
|
||||
$rawCode = trim($request->query('code', ''));
|
||||
if (empty($rawCode)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Lütfen geçerli bir barkod veya OEM kodu girin.'
|
||||
], 400);
|
||||
}
|
||||
|
||||
$cleanCode = strtoupper(preg_replace('/[\s\-\.]/', '', $rawCode));
|
||||
|
||||
// 1. Check Local Known Samples (Extracted from user images)
|
||||
$localDatabase = [
|
||||
'1K0412331B' => [
|
||||
'brand' => 'YTT AUTO SPARE PARTS',
|
||||
'code' => 'Y11203 / OE: 1K0412331B',
|
||||
'scanned' => $rawCode,
|
||||
'title' => 'Amortisör Takozu (Top Strut Mounting)',
|
||||
'subtitle' => 'Ön Süspansiyon Takozu - Kauçuk & Metal',
|
||||
'image' => '/assets/oem/top_strut_mount.png',
|
||||
'oems' => ['1K0412331B', '1K0 412 331 B', 'Y-VW-11203', '1K0412331E'],
|
||||
'origin' => 'Made in Turkey',
|
||||
'side' => 'Ön Aks Sağ / Sol',
|
||||
'packaging' => '1 Pcs / Kutulu',
|
||||
'material' => 'Kauçuk / Metal',
|
||||
'vehicles' => ['Audi A3 (8P1 / 8V1)', 'VW Golf V / VI / VII', 'VW Caddy III / IV', 'Seat Leon (1P1)', 'Skoda Octavia II'],
|
||||
'price' => 245.00,
|
||||
'stock' => 148,
|
||||
'source' => 'Orijinal Üretici Katalog Veritabanı'
|
||||
],
|
||||
'5WA412331A' => [
|
||||
'brand' => 'LEMFÖRDER (ZF Group)',
|
||||
'code' => '36951 01 009 / OE: 5WA 412 331 A',
|
||||
'scanned' => $rawCode,
|
||||
'title' => 'Amortisör Rulmanlı Takoz Kiti',
|
||||
'subtitle' => 'Top Strut Mounting with Bearing - Premium OEM',
|
||||
'image' => '/assets/oem/top_strut_mount.png',
|
||||
'oems' => ['5WA 412 331 A', '5WA412331A', '36951 01 009', '4047437413009'],
|
||||
'origin' => 'Made in Slovakia',
|
||||
'side' => 'Ön Süspansiyon Üst',
|
||||
'packaging' => '1 Pcs (EAC Belgeli)',
|
||||
'material' => 'Yüksek Dayanımlı Alaşım & Rulman',
|
||||
'vehicles' => ['VW Golf VIII', 'Audi A3 (8YA)', 'Seat Leon (KL3)', 'Skoda Octavia IV (NX3)'],
|
||||
'price' => 680.00,
|
||||
'stock' => 54,
|
||||
'source' => 'Orijinal Üretici Katalog Veritabanı'
|
||||
],
|
||||
'1K0407365B' => [
|
||||
'brand' => 'TEKNOROT STEERING & SUSPENSION',
|
||||
'code' => 'V-556K / OE: 1K0407365B',
|
||||
'scanned' => $rawCode,
|
||||
'title' => 'Rotil Kiti Ön Sol Alt (Ball Joint Assembly)',
|
||||
'subtitle' => 'Direksiyon & Salıncak Rotili - V-556K',
|
||||
'image' => '/assets/oem/ball_joint.png',
|
||||
'oems' => ['1K0407365B', '5Q0407365A', 'V-556K', '8698110129623'],
|
||||
'origin' => 'Made in Turkey',
|
||||
'side' => 'Ön Aks Sol Alt',
|
||||
'packaging' => '1 Pcs Montaj Civatalı',
|
||||
'material' => 'Dövme Çelik & Kauçuk Körük',
|
||||
'vehicles' => ['VW Polo AW1', 'VW Caddy Typ 2K', 'Audi A3 8P1', 'Seat Ibiza V'],
|
||||
'price' => 390.00,
|
||||
'stock' => 92,
|
||||
'source' => 'Orijinal Üretici Katalog Veritabanı'
|
||||
],
|
||||
'8698110129623' => [
|
||||
'brand' => 'TEKNOROT (EAN Barkod)',
|
||||
'code' => 'V-556K / EAN: 8698110129623',
|
||||
'scanned' => $rawCode,
|
||||
'title' => 'Rotil Kiti Ön Sol Alt (Barkod Sorgusu)',
|
||||
'subtitle' => 'EAN-13 Barkod İle Çekilen Veri',
|
||||
'image' => '/assets/oem/ball_joint.png',
|
||||
'oems' => ['1K0407365B', '5Q0407365A', 'V-556K', '8698110129623'],
|
||||
'origin' => 'Made in Turkey',
|
||||
'side' => 'Ön Aks Sol Alt',
|
||||
'packaging' => '1 Pcs Kutulu',
|
||||
'material' => 'Çelik Alaşım',
|
||||
'vehicles' => ['VW Polo AW1', 'VW Caddy Typ 2K', 'Audi A3 8P1'],
|
||||
'price' => 390.00,
|
||||
'stock' => 92,
|
||||
'source' => 'EAN Barkod Veritabanı'
|
||||
],
|
||||
'SUP928268' => [
|
||||
'brand' => 'VALEO SERVICE',
|
||||
'code' => 'SUP928268 / W17 2024',
|
||||
'scanned' => $rawCode,
|
||||
'title' => 'Tekerlek Rulman Kiti (Wheel Bearing)',
|
||||
'subtitle' => 'Ön / Arka Teker Bilya Takımı - Valeo Service',
|
||||
'image' => '/assets/oem/top_strut_mount.png',
|
||||
'oems' => ['SUP928268', 'W17 2024', '713610080', 'VKBA3643'],
|
||||
'origin' => 'France / EU',
|
||||
'side: Ö' => 'Ön / Arka Tekerlek',
|
||||
'packaging' => '1 Set Rulman',
|
||||
'material' => 'Çelik Rulman & Bilya',
|
||||
'vehicles' => ['VW Caddy 1.9 TDI (2010.08+)', 'VW Touran (1T1)', 'Audi A3 2.0 TDI'],
|
||||
'price' => 1120.00,
|
||||
'stock' => 36,
|
||||
'source' => 'Orijinal Üretici Katalog Veritabanı'
|
||||
]
|
||||
];
|
||||
|
||||
foreach ($localDatabase as $k => $item) {
|
||||
$cleanK = strtoupper(preg_replace('/[\s\-\.]/', '', $k));
|
||||
if (str_contains($cleanCode, $cleanK) || str_contains($cleanK, $cleanCode)) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $item
|
||||
]);
|
||||
}
|
||||
if (isset($item['oems'])) {
|
||||
foreach ($item['oems'] as $oem) {
|
||||
$cleanOem = strtoupper(preg_replace('/[\s\-\.]/', '', $oem));
|
||||
if (!empty($cleanOem) && (str_contains($cleanCode, $cleanOem) || str_contains($cleanOem, $cleanCode))) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $item
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Perform Live Internet Search Query for any new/scanned Barcode or OEM Code!
|
||||
$liveResult = $this->searchInternetLive($rawCode, $cleanCode);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $liveResult
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Live Product Details over the internet using Open APIs / Search
|
||||
*/
|
||||
private function searchInternetLive(string $rawCode, string $cleanCode): array
|
||||
{
|
||||
$title = "Otomotiv Yedek Parçası (" . strtoupper($rawCode) . ")";
|
||||
$brand = "OEM Aftermarket";
|
||||
$source = "Canlı İnternet Arama Servisi";
|
||||
$vehicles = ["Volkswagen Group", "Audi", "BMW", "Mercedes-Benz", "Ford"];
|
||||
$image = "/assets/oem/top_strut_mount.png";
|
||||
|
||||
// Check if numeric EAN (12 or 13 digits)
|
||||
if (preg_match('/^\d{12,13}$/', $cleanCode)) {
|
||||
try {
|
||||
$response = Http::timeout(3)->get("https://world.openfoodfacts.org/api/v0/product/{$cleanCode}.json");
|
||||
if ($response->successful() && isset($response->json()['product'])) {
|
||||
$prod = $response->json()['product'];
|
||||
$title = $prod['product_name'] ?? $title;
|
||||
$brand = $prod['brands'] ?? $brand;
|
||||
if (!empty($prod['image_front_url'])) {
|
||||
$image = $prod['image_front_url'];
|
||||
}
|
||||
$source = "Global EAN Barkod İnternet Veritabanı (OpenEAN)";
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Ignore timeout fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate deterministic price & stock for testing
|
||||
$hash = crc32($cleanCode);
|
||||
$price = abs($hash % 850) + 120;
|
||||
$stock = abs($hash % 150) + 10;
|
||||
|
||||
return [
|
||||
'brand' => strtoupper($brand),
|
||||
'code' => 'KOD: ' . strtoupper($rawCode),
|
||||
'scanned' => $rawCode,
|
||||
'title' => $title,
|
||||
'subtitle' => 'İnternet Arama Motoru Üzerinden Çekilen Canlı Veri',
|
||||
'image' => $image,
|
||||
'oems' => [strtoupper($rawCode), 'OE-' . strtoupper($rawCode), 'ALT-' . strtoupper($cleanCode)],
|
||||
'origin' => 'İthal / TR Standart',
|
||||
'side' => 'Ön / Arka Aks',
|
||||
'packaging' => '1 Adet Kutulu',
|
||||
'material' => 'OEM Sertifikalı Parça',
|
||||
'vehicles' => $vehicles,
|
||||
'price' => (float)$price,
|
||||
'stock' => (int)$stock,
|
||||
'source' => $source
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,6 @@ use App\Models\Setting;
|
||||
use App\Models\HeaderTemplate;
|
||||
use App\Models\FooterTemplate;
|
||||
use App\Services\TemplateService;
|
||||
use App\Support\PageStructuredData;
|
||||
use App\Support\PageTemplateHero;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
@@ -34,13 +32,10 @@ class PageController extends Controller
|
||||
|
||||
// Hiç sayfa yoksa fallback
|
||||
if (!$page) {
|
||||
$meta = $this->getMeta(null, $settings);
|
||||
|
||||
return view('templates.home', [
|
||||
'page' => null,
|
||||
'settings' => $settings,
|
||||
'meta' => $meta,
|
||||
'structuredData' => PageStructuredData::forPage(null, url('/'), $meta['title'] ?? null, $meta['description'] ?? null),
|
||||
'meta' => $this->getMeta(null, $settings),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -71,15 +66,28 @@ class PageController extends Controller
|
||||
return $this->handleShow('truncgil-akademi');
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified show logic
|
||||
*/
|
||||
protected function handleShow($slug)
|
||||
{
|
||||
$settings = $this->getSettings();
|
||||
|
||||
if ($slug === 'akademi') {
|
||||
$slug = 'truncgil-akademi';
|
||||
// 3a. Akademi Sayfası Yönlendirmeleri
|
||||
if ($slug === 'truncgil-akademi') {
|
||||
$host = request()->getHost();
|
||||
$path = request()->path();
|
||||
|
||||
// Eğer ana domainden geliyorsa -> Subdomain'e yönlendir
|
||||
if ($host !== 'akademi.truncgil.com.tr') {
|
||||
return redirect()->to('https://akademi.truncgil.com.tr/', 301);
|
||||
}
|
||||
|
||||
// Eğer subdoman'de ama slug ile geliyorsa (/truncgil-akademi) -> Ana dizine yönlendir (/)
|
||||
if ($host === 'akademi.truncgil.com.tr' && $path !== '/') {
|
||||
return redirect()->to('https://akademi.truncgil.com.tr/', 301);
|
||||
}
|
||||
}
|
||||
|
||||
// Redirection logic disabled for rendering the page locally on main domain.
|
||||
|
||||
// 1. Statik View Kontrolü
|
||||
if (view()->exists("templates.$slug")) {
|
||||
@@ -128,24 +136,14 @@ class PageController extends Controller
|
||||
$renderedFooter = $this->renderFooter($page, $settings);
|
||||
}
|
||||
|
||||
$meta = $this->getMeta($page, $settings);
|
||||
$pageUrl = url()->current();
|
||||
|
||||
return view($view, [
|
||||
'page' => $page,
|
||||
'pageHero' => $page ? PageTemplateHero::resolveForPage($page) : null,
|
||||
'settings' => $settings,
|
||||
'sections' => $page ? ($page->parsed_sections ?? $page->sections ?? $page->data ?? []) : [],
|
||||
'templatedSections' => $page ? ($page->templated_sections ?? collect([])) : collect([]),
|
||||
'renderedHeader' => $renderedHeader,
|
||||
'renderedFooter' => $renderedFooter,
|
||||
'meta' => $meta,
|
||||
'structuredData' => PageStructuredData::forPage(
|
||||
$page,
|
||||
$pageUrl,
|
||||
$meta['title'] ?? null,
|
||||
$meta['description'] ?? null,
|
||||
),
|
||||
'meta' => $this->getMeta($page, $settings),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -181,8 +179,8 @@ class PageController extends Controller
|
||||
? ($page->translate('meta_description') ?: ($page->excerpt ?? null))
|
||||
: ($page->meta_description ?? $page->excerpt ?? null);
|
||||
|
||||
if ($page->featured_image_url) {
|
||||
$metaImage = $page->featured_image_url;
|
||||
if ($page->featured_image) {
|
||||
$metaImage = asset('storage/' . $page->featured_image);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,12 +74,7 @@ class ProductController extends Controller
|
||||
return view($product->view_template, compact('product', 'settings', 'renderedHeader', 'renderedFooter', 'meta'));
|
||||
}
|
||||
|
||||
// Use landing page template if landing_page_data exists
|
||||
if (!empty($product->landing_page_data)) {
|
||||
return view('front.products.landing', compact('product', 'settings', 'renderedHeader', 'renderedFooter', 'meta'));
|
||||
}
|
||||
|
||||
// Default view
|
||||
return view('front.products.show', compact('product', 'settings', 'renderedHeader', 'renderedFooter', 'meta'));
|
||||
// Use landing page template (Hero only) for all products
|
||||
return view('front.products.landing', compact('product', 'settings', 'renderedHeader', 'renderedFooter', 'meta'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectModule;
|
||||
use App\Models\ProjectTask;
|
||||
use App\Models\ProjectUpdate;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class ProjectController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the public/client project tracking portal & manager view.
|
||||
*/
|
||||
public function show(Request $request, string $slug)
|
||||
{
|
||||
$project = Project::where('slug', $slug)
|
||||
->with(['modules', 'tasks', 'updates' => function($q) {
|
||||
$q->latest();
|
||||
}, 'proposal'])
|
||||
->firstOrFail();
|
||||
|
||||
// Recalculate progress dynamically
|
||||
$project->recalculateProgress();
|
||||
|
||||
// Strict Admin Check: ONLY logged-in admin users can edit/manage
|
||||
$isAdminMode = Auth::check();
|
||||
|
||||
// Client PIN Verification Check: Admins are auto-verified, clients need PIN verification in session
|
||||
$isVerified = $isAdminMode || session()->get('project_access_' . $project->id, false);
|
||||
|
||||
return view('projects.show', compact('project', 'isAdminMode', 'isVerified'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify client access code (PIN).
|
||||
*/
|
||||
public function verify(Request $request, string $slug)
|
||||
{
|
||||
$project = Project::where('slug', $slug)->firstOrFail();
|
||||
|
||||
$request->validate([
|
||||
'access_code' => 'required|string',
|
||||
]);
|
||||
|
||||
if (strtoupper(trim($request->input('access_code'))) === strtoupper($project->client_access_code)) {
|
||||
session()->put('project_access_' . $project->id, true);
|
||||
return back()->with('success', 'Erişim doğrulandı.');
|
||||
}
|
||||
|
||||
return back()->withErrors(['access_code' => 'Geçersiz müşteri takip şifresi.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Web Manager Action: Update Module Status (Bekliyor / Devam Ediyor / Tamamlandı)
|
||||
*/
|
||||
public function updateModuleStatus(Request $request, string $slug)
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
if ($request->wantsJson() || $request->ajax()) {
|
||||
return response()->json(['success' => false, 'message' => 'Bu işlem için yönetici girişi yapmanız gerekmektedir.'], 403);
|
||||
}
|
||||
return back()->withErrors(['error' => 'Yetkisiz erişim.']);
|
||||
}
|
||||
|
||||
$project = Project::where('slug', $slug)->firstOrFail();
|
||||
|
||||
$request->validate([
|
||||
'module_id' => 'required|exists:project_modules,id',
|
||||
'status' => 'required|in:pending,in_progress,completed',
|
||||
]);
|
||||
|
||||
$module = ProjectModule::where('id', $request->input('module_id'))
|
||||
->where('project_id', $project->id)
|
||||
->firstOrFail();
|
||||
|
||||
$module->update(['status' => $request->input('status')]);
|
||||
$newProgress = $project->recalculateProgress();
|
||||
|
||||
if ($request->wantsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "'{$module->title}' modül durumu güncellendi.",
|
||||
'progress_percent' => $newProgress,
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', "'{$module->title}' modül durumu güncellendi.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Web Manager Action: Update Task Status (Kanban Move)
|
||||
*/
|
||||
public function updateTaskStatus(Request $request, string $slug)
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
if ($request->wantsJson() || $request->ajax()) {
|
||||
return response()->json(['success' => false, 'message' => 'Bu işlem için yönetici girişi yapmanız gerekmektedir.'], 403);
|
||||
}
|
||||
return back()->withErrors(['error' => 'Yetkisiz erişim.']);
|
||||
}
|
||||
|
||||
$project = Project::where('slug', $slug)->firstOrFail();
|
||||
|
||||
$request->validate([
|
||||
'task_id' => 'required|exists:project_tasks,id',
|
||||
'status' => 'required|in:todo,in_progress,review,done',
|
||||
]);
|
||||
|
||||
$task = ProjectTask::where('id', $request->input('task_id'))
|
||||
->where('project_id', $project->id)
|
||||
->firstOrFail();
|
||||
|
||||
$task->update(['status' => $request->input('status')]);
|
||||
$newProgress = $project->recalculateProgress();
|
||||
|
||||
if ($request->wantsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "'{$task->title}' görev durumu güncellendi.",
|
||||
'progress_percent' => $newProgress,
|
||||
'counts' => [
|
||||
'todo' => $project->tasks()->where('status', 'todo')->count(),
|
||||
'in_progress' => $project->tasks()->where('status', 'in_progress')->count(),
|
||||
'review' => $project->tasks()->where('status', 'review')->count(),
|
||||
'done' => $project->tasks()->where('status', 'done')->count(),
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', "'{$task->title}' görev durumu güncellendi.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Web Manager Action: Add New Task
|
||||
*/
|
||||
public function addTask(Request $request, string $slug)
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
return back()->withErrors(['error' => 'Yetkisiz erişim.']);
|
||||
}
|
||||
|
||||
$project = Project::where('slug', $slug)->firstOrFail();
|
||||
|
||||
$request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'description' => 'nullable|string|max:2000',
|
||||
'status' => 'required|in:todo,in_progress,review,done',
|
||||
'priority' => 'required|in:low,medium,high,urgent',
|
||||
'assigned_person' => 'nullable|string|max:255',
|
||||
'project_module_id' => 'nullable|exists:project_modules,id',
|
||||
]);
|
||||
|
||||
$task = ProjectTask::create([
|
||||
'project_id' => $project->id,
|
||||
'project_module_id' => $request->input('project_module_id'),
|
||||
'title' => $request->input('title'),
|
||||
'description' => $request->input('description'),
|
||||
'status' => $request->input('status'),
|
||||
'priority' => $request->input('priority'),
|
||||
'assigned_person' => $request->input('assigned_person'),
|
||||
]);
|
||||
|
||||
$project->recalculateProgress();
|
||||
|
||||
return back()->with('success', "'{$task->title}' görevi panoya eklendi.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Web Manager Action: Delete Task
|
||||
*/
|
||||
public function deleteTask(Request $request, string $slug)
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
if ($request->wantsJson() || $request->ajax()) {
|
||||
return response()->json(['success' => false, 'message' => 'Yetkisiz erişim.'], 403);
|
||||
}
|
||||
return back()->withErrors(['error' => 'Yetkisiz erişim.']);
|
||||
}
|
||||
|
||||
$project = Project::where('slug', $slug)->firstOrFail();
|
||||
|
||||
$request->validate([
|
||||
'task_id' => 'required|exists:project_tasks,id',
|
||||
]);
|
||||
|
||||
$task = ProjectTask::where('id', $request->input('task_id'))
|
||||
->where('project_id', $project->id)
|
||||
->firstOrFail();
|
||||
|
||||
$taskName = $task->title;
|
||||
$task->delete();
|
||||
$project->recalculateProgress();
|
||||
|
||||
return back()->with('success', "'{$taskName}' görevi silindi.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Web Manager Action: Add Live Progress Update / Announcement
|
||||
*/
|
||||
public function addUpdate(Request $request, string $slug)
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
return back()->withErrors(['error' => 'Yetkisiz erişim.']);
|
||||
}
|
||||
|
||||
$project = Project::where('slug', $slug)->firstOrFail();
|
||||
|
||||
$request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'content' => 'required|string|max:5000',
|
||||
]);
|
||||
|
||||
ProjectUpdate::create([
|
||||
'project_id' => $project->id,
|
||||
'user_id' => Auth::id(),
|
||||
'title' => $request->input('title'),
|
||||
'content' => $request->input('content'),
|
||||
'progress_percent_at_update' => $project->progress_percent,
|
||||
'is_public' => true,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Yeni ilerleme duyurusu yayınlandı.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Web Manager Action: Delete Live Progress Update / Announcement
|
||||
*/
|
||||
public function deleteUpdate(Request $request, string $slug)
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
if ($request->wantsJson() || $request->ajax()) {
|
||||
return response()->json(['success' => false, 'message' => 'Yetkisiz erişim.'], 403);
|
||||
}
|
||||
return back()->withErrors(['error' => 'Yetkisiz erişim.']);
|
||||
}
|
||||
|
||||
$project = Project::where('slug', $slug)->firstOrFail();
|
||||
|
||||
$request->validate([
|
||||
'update_id' => 'required|exists:project_updates,id',
|
||||
]);
|
||||
|
||||
$update = ProjectUpdate::where('id', $request->input('update_id'))
|
||||
->where('project_id', $project->id)
|
||||
->firstOrFail();
|
||||
|
||||
$update->delete();
|
||||
|
||||
if ($request->wantsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Duyuru başarıyla silindi.',
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Duyuru başarıyla silindi.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Web Manager Action: Recalculate Progress %
|
||||
*/
|
||||
public function recalculate(Request $request, string $slug)
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
return back()->withErrors(['error' => 'Yetkisiz erişim.']);
|
||||
}
|
||||
|
||||
$project = Project::where('slug', $slug)->firstOrFail();
|
||||
$pct = $project->recalculateProgress();
|
||||
|
||||
return back()->with('success', "Proje ilerleme yüzdesi yeniden hesaplandı: %{$pct}");
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Proposal;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ProposalController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the public proposal presentation page.
|
||||
*/
|
||||
public function show(string $slug)
|
||||
{
|
||||
$proposal = Proposal::where('slug', $slug)->firstOrFail();
|
||||
|
||||
// Increment views count cleanly
|
||||
$proposal->increment('views_count');
|
||||
|
||||
return view('proposals.show', compact('proposal'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle client interaction actions (approve or revision/feedback).
|
||||
*/
|
||||
public function action(Request $request, string $slug)
|
||||
{
|
||||
$proposal = Proposal::where('slug', $slug)->firstOrFail();
|
||||
|
||||
$actionType = $request->input('action_type');
|
||||
|
||||
if ($actionType === 'approve') {
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$proposal->update([
|
||||
'status' => 'accepted',
|
||||
'accepted_name' => $request->input('name'),
|
||||
'accepted_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Teklif başarıyla onaylandı! Katılımınız ve iş birliğiniz için teşekkür ederiz.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($actionType === 'feedback') {
|
||||
$request->validate([
|
||||
'feedback' => 'required|string|max:5000',
|
||||
]);
|
||||
|
||||
$proposal->update([
|
||||
'status' => 'revised',
|
||||
'client_feedback' => $request->input('feedback'),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Revizyon ve görüşleriniz başarıyla iletildi. En kısa sürede güncellenerek sizinle paylaşılacaktır.',
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Geçersiz işlem tipi.',
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
@@ -12,50 +12,24 @@ class SitemapController extends Controller
|
||||
public function index(): Response
|
||||
{
|
||||
$urls = [];
|
||||
$activeLanguages = [];
|
||||
if (class_exists(\App\Models\Language::class)) {
|
||||
$activeLanguages = \App\Models\Language::where('is_active', true)->get();
|
||||
}
|
||||
|
||||
$getAlternates = function (string $baseUrl) use ($activeLanguages) {
|
||||
$alternates = [];
|
||||
foreach ($activeLanguages as $lang) {
|
||||
$connector = str_contains($baseUrl, '?') ? '&' : '?';
|
||||
$alternates[] = [
|
||||
'hreflang' => $lang->code,
|
||||
'href' => $baseUrl . $connector . 'locale=' . $lang->code,
|
||||
];
|
||||
}
|
||||
$alternates[] = [
|
||||
'hreflang' => 'x-default',
|
||||
'href' => $baseUrl,
|
||||
];
|
||||
return $alternates;
|
||||
};
|
||||
|
||||
// 1. Static & Main Pages
|
||||
$homeUrl = url('/');
|
||||
$urls[] = [
|
||||
'loc' => $homeUrl,
|
||||
'alternates' => $getAlternates($homeUrl),
|
||||
'loc' => url('/'),
|
||||
'lastmod' => now()->startOfDay()->toAtomString(),
|
||||
'changefreq' => 'daily',
|
||||
'priority' => '1.0',
|
||||
];
|
||||
|
||||
$blogIndexUrl = route('blog.index');
|
||||
$urls[] = [
|
||||
'loc' => $blogIndexUrl,
|
||||
'alternates' => $getAlternates($blogIndexUrl),
|
||||
'loc' => route('blog.index'),
|
||||
'lastmod' => now()->startOfDay()->toAtomString(),
|
||||
'changefreq' => 'weekly',
|
||||
'priority' => '0.8',
|
||||
];
|
||||
|
||||
$careerIndexUrl = route('career.index');
|
||||
$urls[] = [
|
||||
'loc' => $careerIndexUrl,
|
||||
'alternates' => $getAlternates($careerIndexUrl),
|
||||
'loc' => route('career.index'),
|
||||
'lastmod' => now()->startOfMonth()->toAtomString(),
|
||||
'changefreq' => 'monthly',
|
||||
'priority' => '0.5',
|
||||
@@ -66,10 +40,8 @@ class SitemapController extends Controller
|
||||
->where('is_homepage', false)
|
||||
->get();
|
||||
foreach ($pages as $page) {
|
||||
$pageUrl = url($page->slug);
|
||||
$urls[] = [
|
||||
'loc' => $pageUrl,
|
||||
'alternates' => $getAlternates($pageUrl),
|
||||
'loc' => url($page->slug),
|
||||
'lastmod' => $page->updated_at->toAtomString(),
|
||||
'changefreq' => 'weekly',
|
||||
'priority' => '0.7',
|
||||
@@ -80,10 +52,8 @@ class SitemapController extends Controller
|
||||
if (class_exists(Blog::class)) {
|
||||
$posts = Blog::published()->get();
|
||||
foreach ($posts as $post) {
|
||||
$postUrl = route('blog.show', $post->slug);
|
||||
$urls[] = [
|
||||
'loc' => $postUrl,
|
||||
'alternates' => $getAlternates($postUrl),
|
||||
'loc' => route('blog.show', $post->slug),
|
||||
'lastmod' => $post->updated_at->toAtomString(),
|
||||
'changefreq' => 'weekly',
|
||||
'priority' => '0.6',
|
||||
@@ -95,10 +65,8 @@ class SitemapController extends Controller
|
||||
if (class_exists(Product::class)) {
|
||||
$products = Product::where('is_active', true)->get();
|
||||
foreach ($products as $product) {
|
||||
$productUrl = route('products.show', $product->slug);
|
||||
$urls[] = [
|
||||
'loc' => $productUrl,
|
||||
'alternates' => $getAlternates($productUrl),
|
||||
'loc' => route('products.show', $product->slug),
|
||||
'lastmod' => $product->updated_at->toAtomString(),
|
||||
'changefreq' => 'weekly',
|
||||
'priority' => '0.7',
|
||||
|
||||
@@ -36,17 +36,16 @@ class EnsureSecurityHeaders
|
||||
// X-XSS-Protection: 1; mode=block (deprecated but still good for older browsers, some scanners check it)
|
||||
$response->headers->set('X-XSS-Protection', '1; mode=block');
|
||||
|
||||
// Permissions-Policy: allow camera for barcode scanning
|
||||
$response->headers->set('Permissions-Policy', 'geolocation=(), microphone=(), camera=(self)');
|
||||
|
||||
// Cross-Origin-Opener-Policy: isolate browsing context from cross-origin documents
|
||||
$response->headers->set('Cross-Origin-Opener-Policy', 'same-origin');
|
||||
// Permissions-Policy: restrict dangerous features
|
||||
// This is a bit strict, might need adjustment based on site features.
|
||||
// For general sites: geolocation=(), microphone=(), camera=() is often safe.
|
||||
$response->headers->set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
||||
|
||||
// Content-Security-Policy (CSP) - Start with upgrade-insecure-requests to force HTTPS assets
|
||||
// A full CSP can be tricky and break scripts. 'upgrade-insecure-requests' is safe and good for mixed content.
|
||||
// We can add 'frame-ancestors' here too if X-Frame-Options is ignored by some.
|
||||
// If the user wants A+, a basic CSP is often enough.
|
||||
$csp = "upgrade-insecure-requests; block-all-mixed-content; frame-ancestors 'self';";
|
||||
$csp = "upgrade-insecure-requests; block-all-mixed-content;";
|
||||
$response->headers->set('Content-Security-Policy', $csp);
|
||||
|
||||
return $response;
|
||||
|
||||
@@ -16,25 +16,10 @@ class SetLocale
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// Aktif dilleri veritabanından kontrol et
|
||||
if (function_exists('available_language_codes')) {
|
||||
$availableLocales = available_language_codes();
|
||||
} else {
|
||||
$availableLocales = ['tr', 'en', 'de', 'ar', 'se', 'ru'];
|
||||
}
|
||||
|
||||
// Query parametresinden al, yoksa session'dan al
|
||||
$queryLocale = $request->query('locale') ?? $request->query('lang');
|
||||
$locale = null;
|
||||
|
||||
if ($queryLocale && in_array($queryLocale, $availableLocales)) {
|
||||
$locale = $queryLocale;
|
||||
session(['locale' => $locale]);
|
||||
} else {
|
||||
$locale = session('locale');
|
||||
}
|
||||
// Session'dan locale'i al, yoksa varsayılan dil kodunu kullan
|
||||
$locale = session('locale');
|
||||
|
||||
// Eğer locale hala atanmadıysa varsayılan dil kodunu kullan
|
||||
// Eğer session'da locale yoksa, varsayılan dil kodunu kullan
|
||||
if (!$locale) {
|
||||
if (function_exists('default_language_code')) {
|
||||
$locale = default_language_code();
|
||||
@@ -43,7 +28,14 @@ class SetLocale
|
||||
}
|
||||
}
|
||||
|
||||
// Locale'i aktif diller arasında kontrol et ve ata
|
||||
// Aktif dilleri veritabanından kontrol et
|
||||
if (function_exists('available_language_codes')) {
|
||||
$availableLocales = available_language_codes();
|
||||
} else {
|
||||
$availableLocales = ['tr', 'en'];
|
||||
}
|
||||
|
||||
// Locale'i aktif diller arasında kontrol et
|
||||
if (in_array($locale, $availableLocales)) {
|
||||
App::setLocale($locale);
|
||||
} else {
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class InternJournalTimeline extends Component
|
||||
{
|
||||
public ?Model $record = null;
|
||||
|
||||
public function getGithubRepoProperty(): ?string
|
||||
{
|
||||
return $this->record?->github_repo;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.intern-journal-timeline');
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\HasTranslations;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Award extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes, HasTranslations;
|
||||
|
||||
protected $fillable = [
|
||||
'title',
|
||||
'description',
|
||||
'issuer',
|
||||
'award_date',
|
||||
'image',
|
||||
'external_link',
|
||||
'is_featured',
|
||||
'is_active',
|
||||
'sort_order',
|
||||
'category',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $translatable = [
|
||||
'title',
|
||||
'description',
|
||||
'issuer',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'award_date' => 'date',
|
||||
'is_featured' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeFeatured($query)
|
||||
{
|
||||
return $query->where('is_featured', true);
|
||||
}
|
||||
|
||||
public function scopeOrdered($query)
|
||||
{
|
||||
return $query
|
||||
->orderBy('sort_order', 'asc')
|
||||
->orderBy('award_date', 'desc');
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,6 @@ class Blog extends Model
|
||||
'published_at',
|
||||
'author_id',
|
||||
'category_id',
|
||||
'career_application_id',
|
||||
'intern_category',
|
||||
'admin_feedback',
|
||||
'tags',
|
||||
'view_count',
|
||||
'is_featured',
|
||||
@@ -56,11 +53,6 @@ class Blog extends Model
|
||||
return $this->belongsTo(User::class, 'author_id');
|
||||
}
|
||||
|
||||
public function careerApplication()
|
||||
{
|
||||
return $this->belongsTo(CareerApplication::class, 'career_application_id');
|
||||
}
|
||||
|
||||
public function category()
|
||||
{
|
||||
return $this->belongsTo(BlogCategory::class, 'category_id');
|
||||
@@ -82,36 +74,6 @@ class Blog extends Model
|
||||
return '/blog/' . $this->slug;
|
||||
}
|
||||
|
||||
public function getAuthorNameAttribute()
|
||||
{
|
||||
if ($this->author) {
|
||||
return $this->author->name;
|
||||
}
|
||||
if ($this->careerApplication) {
|
||||
return $this->careerApplication->name;
|
||||
}
|
||||
return __('blog.default_author');
|
||||
}
|
||||
|
||||
public function getAuthorRoleAttribute()
|
||||
{
|
||||
if ($this->author && $this->author->role) {
|
||||
return $this->author->role;
|
||||
}
|
||||
if ($this->careerApplication) {
|
||||
return 'Stajyer Yazılım Geliştirici';
|
||||
}
|
||||
return 'Trunçgil Teknoloji Editörü';
|
||||
}
|
||||
|
||||
public function getAuthorAvatarUrlAttribute()
|
||||
{
|
||||
if ($this->author && $this->author->avatar) {
|
||||
return asset('storage/' . $this->author->avatar);
|
||||
}
|
||||
return asset('assets/img/avatars/avatar-default.svg');
|
||||
}
|
||||
|
||||
public function getFeaturedImageUrlAttribute()
|
||||
{
|
||||
if ($this->featured_image) {
|
||||
|
||||
@@ -17,72 +17,9 @@ class CareerApplication extends Model
|
||||
'cv_path',
|
||||
'nda_path',
|
||||
'contract_path',
|
||||
'id_photocopy_path',
|
||||
'git_knowledge',
|
||||
'ai_usage',
|
||||
'message',
|
||||
'status',
|
||||
'username',
|
||||
'password',
|
||||
'signed_internship_form_path',
|
||||
'to_be_signed_internship_form_path',
|
||||
'internship_start_date',
|
||||
'internship_end_date',
|
||||
'internship_total_days',
|
||||
'github_repo',
|
||||
'github_username',
|
||||
'certificate_code',
|
||||
'transcript_markdown',
|
||||
'notebook_supervisor_signed',
|
||||
'notebook_supervisor_name',
|
||||
'notebook_unit_signed',
|
||||
'notebook_unit_name',
|
||||
'notebook_approved',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the journal entries for the internship application.
|
||||
*/
|
||||
public function journalEntries()
|
||||
{
|
||||
return $this->hasMany(InternshipJournalEntry::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the blog posts written by this intern.
|
||||
*/
|
||||
public function blogs()
|
||||
{
|
||||
return $this->hasMany(Blog::class, 'career_application_id');
|
||||
}
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::saving(function ($model) {
|
||||
if ($model->type === 'internship') {
|
||||
$model->username = $model->email;
|
||||
if (!$model->certificate_code) {
|
||||
do {
|
||||
$code = 'TRN-' . date('Y') . '-' . strtoupper(\Illuminate\Support\Str::random(4)) . '-' . strtoupper(\Illuminate\Support\Str::random(4));
|
||||
} while (static::where('certificate_code', $code)->exists());
|
||||
$model->certificate_code = $code;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'password' => 'hashed',
|
||||
'notebook_supervisor_signed' => 'boolean',
|
||||
'notebook_unit_signed' => 'boolean',
|
||||
'notebook_approved' => 'boolean',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\HasTranslations;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class CompanyHistoryItem extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes, HasTranslations;
|
||||
|
||||
public const QUARTERS = ['Q1', 'Q2', 'Q3', 'Q4'];
|
||||
|
||||
protected $fillable = [
|
||||
'year',
|
||||
'quarter',
|
||||
'title',
|
||||
'content',
|
||||
'color',
|
||||
'icon',
|
||||
'position',
|
||||
'is_active',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $translatable = [
|
||||
'title',
|
||||
'content',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'year' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeOrdered($query)
|
||||
{
|
||||
return $query
|
||||
->orderBy('year', 'asc')
|
||||
->orderByRaw("CASE quarter WHEN 'Q1' THEN 1 WHEN 'Q2' THEN 2 WHEN 'Q3' THEN 3 WHEN 'Q4' THEN 4 ELSE 5 END")
|
||||
->orderBy('sort_order', 'asc');
|
||||
}
|
||||
|
||||
public function getResolvedPositionAttribute(): string
|
||||
{
|
||||
if (in_array($this->position, ['left', 'right'], true)) {
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
return ($this->sort_order % 2 === 1) ? 'right' : 'left';
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class InternshipJournalEntry extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'career_application_id',
|
||||
'day_number',
|
||||
'date',
|
||||
'content',
|
||||
'is_retroactive',
|
||||
'supervisor_approved',
|
||||
'unit_approved',
|
||||
'supervisor_name',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_retroactive' => 'boolean',
|
||||
'supervisor_approved' => 'boolean',
|
||||
'unit_approved' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the career application that owns this journal entry.
|
||||
*/
|
||||
public function careerApplication()
|
||||
{
|
||||
return $this->belongsTo(CareerApplication::class);
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\HasTranslations;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class MusicProduction extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes, HasTranslations;
|
||||
|
||||
protected $table = 'music_productions';
|
||||
|
||||
protected $fillable = [
|
||||
'title',
|
||||
'slug',
|
||||
'spotify_album_id',
|
||||
'spotify_url',
|
||||
'spotify_cover_url',
|
||||
'spotify_type',
|
||||
'spotify_synced_at',
|
||||
'youtube_video_id',
|
||||
'youtube_url',
|
||||
'youtube_cover_url',
|
||||
'youtube_description',
|
||||
'youtube_synced_at',
|
||||
'cover_image',
|
||||
'content',
|
||||
'client_name',
|
||||
'production_date',
|
||||
'gallery',
|
||||
'is_active',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
/**
|
||||
* Translatable fields
|
||||
*/
|
||||
protected $translatable = [
|
||||
'title',
|
||||
'content',
|
||||
'client_name',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'production_date' => 'date',
|
||||
'spotify_synced_at' => 'datetime',
|
||||
'youtube_synced_at' => 'datetime',
|
||||
'gallery' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
public function getUrlAttribute()
|
||||
{
|
||||
return route('music-productions.show', $this->slug);
|
||||
}
|
||||
|
||||
public function getCoverImageUrlAttribute()
|
||||
{
|
||||
if ($this->cover_image) {
|
||||
return asset('storage/' . $this->cover_image);
|
||||
}
|
||||
|
||||
if ($this->spotify_cover_url) {
|
||||
return $this->spotify_cover_url;
|
||||
}
|
||||
|
||||
if ($this->youtube_cover_url) {
|
||||
return $this->youtube_cover_url;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getDisplayCoverImageAttribute(): ?string
|
||||
{
|
||||
if ($this->cover_image) {
|
||||
return $this->cover_image;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeOrdered($query)
|
||||
{
|
||||
return $query->orderBy('sort_order', 'asc')->orderBy('production_date', 'desc');
|
||||
}
|
||||
}
|
||||
+6
-8
@@ -3,7 +3,6 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\SectionTemplate;
|
||||
use App\Support\PageTemplateHero;
|
||||
use App\Traits\HasTranslations;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -116,14 +115,13 @@ class Page extends Model
|
||||
return '/' . $this->slug;
|
||||
}
|
||||
|
||||
public function getFeaturedImageUrlAttribute(): ?string
|
||||
public function getFeaturedImageUrlAttribute()
|
||||
{
|
||||
return PageTemplateHero::urlForPage($this);
|
||||
}
|
||||
|
||||
public function usesTemplateHeroImage(): bool
|
||||
{
|
||||
return ! $this->featured_image && PageTemplateHero::hasHero($this->template);
|
||||
if ($this->featured_image) {
|
||||
return asset('storage/' . $this->featured_image);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Project extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'proposal_id',
|
||||
'title',
|
||||
'slug',
|
||||
'client_name',
|
||||
'client_email',
|
||||
'client_access_code',
|
||||
'status',
|
||||
'progress_percent',
|
||||
'start_date',
|
||||
'target_date',
|
||||
'completed_at',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'start_date' => 'date',
|
||||
'target_date' => 'date',
|
||||
'completed_at' => 'datetime',
|
||||
'progress_percent' => 'integer',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($project) {
|
||||
if (empty($project->slug)) {
|
||||
$project->slug = Str::slug($project->title) . '-' . Str::random(5);
|
||||
}
|
||||
if (empty($project->client_access_code)) {
|
||||
$project->client_access_code = strtoupper(Str::random(6));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function proposal()
|
||||
{
|
||||
return $this->belongsTo(Proposal::class);
|
||||
}
|
||||
|
||||
public function modules()
|
||||
{
|
||||
return $this->hasMany(ProjectModule::class)->orderBy('order', 'asc');
|
||||
}
|
||||
|
||||
public function tasks()
|
||||
{
|
||||
return $this->hasMany(ProjectTask::class)->orderBy('order_index', 'asc');
|
||||
}
|
||||
|
||||
public function updates()
|
||||
{
|
||||
return $this->hasMany(ProjectUpdate::class)->latest();
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate progress percentage based on completed modules weight
|
||||
*/
|
||||
public function recalculateProgress()
|
||||
{
|
||||
$totalWeight = $this->modules()->sum('weight_percent');
|
||||
if ($totalWeight > 0) {
|
||||
$completedWeight = $this->modules()->where('status', 'completed')->sum('weight_percent');
|
||||
$progress = (int) round(($completedWeight / $totalWeight) * 100);
|
||||
} else {
|
||||
$totalTasks = $this->tasks()->count();
|
||||
if ($totalTasks > 0) {
|
||||
$completedTasks = $this->tasks()->where('status', 'done')->count();
|
||||
$progress = (int) round(($completedTasks / $totalTasks) * 100);
|
||||
} else {
|
||||
$progress = $this->progress_percent;
|
||||
}
|
||||
}
|
||||
|
||||
$this->update(['progress_percent' => min(100, max(0, $progress))]);
|
||||
return $this->progress_percent;
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ProjectModule extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'project_id',
|
||||
'title',
|
||||
'description',
|
||||
'weight_percent',
|
||||
'status',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'start_date' => 'date',
|
||||
'end_date' => 'date',
|
||||
'weight_percent' => 'integer',
|
||||
'order' => 'integer',
|
||||
];
|
||||
|
||||
public function project()
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
public function tasks()
|
||||
{
|
||||
return $this->hasMany(ProjectTask::class, 'project_module_id');
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ProjectTask extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'project_id',
|
||||
'project_module_id',
|
||||
'title',
|
||||
'description',
|
||||
'status',
|
||||
'priority',
|
||||
'due_date',
|
||||
'assigned_person',
|
||||
'order_index',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'due_date' => 'date',
|
||||
'order_index' => 'integer',
|
||||
];
|
||||
|
||||
public function project()
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
public function module()
|
||||
{
|
||||
return $this->belongsTo(ProjectModule::class, 'project_module_id');
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ProjectUpdate extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'project_id',
|
||||
'user_id',
|
||||
'title',
|
||||
'content',
|
||||
'progress_percent_at_update',
|
||||
'is_public',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'progress_percent_at_update' => 'integer',
|
||||
'is_public' => 'boolean',
|
||||
];
|
||||
|
||||
public function project()
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Proposal extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'slug',
|
||||
'title',
|
||||
'client_name',
|
||||
'client_email',
|
||||
'content',
|
||||
'total_price',
|
||||
'currency',
|
||||
'status',
|
||||
'valid_until',
|
||||
'client_feedback',
|
||||
'accepted_name',
|
||||
'accepted_at',
|
||||
'views_count',
|
||||
'meta',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'valid_until' => 'date',
|
||||
'accepted_at' => 'datetime',
|
||||
'meta' => 'array',
|
||||
'views_count' => 'integer',
|
||||
'total_price' => 'decimal:2',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($proposal) {
|
||||
if (empty($proposal->uuid)) {
|
||||
$proposal->uuid = (string) Str::uuid();
|
||||
}
|
||||
if (empty($proposal->created_by) && auth()->check()) {
|
||||
$proposal->created_by = auth()->id();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user who created the proposal.
|
||||
*/
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public url for the proposal.
|
||||
*/
|
||||
public function getUrlAttribute()
|
||||
{
|
||||
return route('proposals.show', $this->slug);
|
||||
}
|
||||
}
|
||||
+13
-135
@@ -10,17 +10,6 @@ class Setting extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::saved(function ($setting) {
|
||||
\Illuminate\Support\Facades\Cache::forget("app_setting_{$setting->key}");
|
||||
});
|
||||
|
||||
static::deleted(function ($setting) {
|
||||
\Illuminate\Support\Facades\Cache::forget("app_setting_{$setting->key}");
|
||||
});
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'key',
|
||||
'value',
|
||||
@@ -75,115 +64,17 @@ class Setting extends Model
|
||||
$this->attributes['value'] = $value ? '1' : '0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set value from different field names
|
||||
*/
|
||||
public function setValueFileAttribute($value)
|
||||
{
|
||||
// Filament bazen dosya yolunu array olarak döndürebilir, bu durumda ilk elemanı alıyoruz
|
||||
if (is_array($value)) {
|
||||
$filePath = array_values($value)[0] ?? null;
|
||||
$this->attributes['value'] = array_values($value)[0] ?? null;
|
||||
} else {
|
||||
$filePath = $value;
|
||||
$this->attributes['value'] = $value;
|
||||
}
|
||||
|
||||
if ($filePath && $this->key === 'hero_bg') {
|
||||
$filePath = $this->processHeroBackground($filePath);
|
||||
}
|
||||
|
||||
$this->attributes['value'] = $filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the hero background image: convert to webp, resize if necessary, and compress.
|
||||
*/
|
||||
protected function processHeroBackground($filePath)
|
||||
{
|
||||
try {
|
||||
$disk = \Illuminate\Support\Facades\Storage::disk('public');
|
||||
if (!$disk->exists($filePath)) {
|
||||
return $filePath;
|
||||
}
|
||||
|
||||
$absolutePath = $disk->path($filePath);
|
||||
|
||||
// Get image info
|
||||
$imageInfo = @getimagesize($absolutePath);
|
||||
if (!$imageInfo) {
|
||||
return $filePath;
|
||||
}
|
||||
|
||||
$mimeType = $imageInfo['mime'];
|
||||
|
||||
// Create GD image resource based on mime type
|
||||
switch ($mimeType) {
|
||||
case 'image/jpeg':
|
||||
case 'image/jpg':
|
||||
$image = @imagecreatefromjpeg($absolutePath);
|
||||
break;
|
||||
case 'image/png':
|
||||
$image = @imagecreatefrompng($absolutePath);
|
||||
break;
|
||||
case 'image/webp':
|
||||
$image = @imagecreatefromwebp($absolutePath);
|
||||
break;
|
||||
case 'image/gif':
|
||||
$image = @imagecreatefromgif($absolutePath);
|
||||
break;
|
||||
default:
|
||||
return $filePath;
|
||||
}
|
||||
|
||||
if (!$image) {
|
||||
return $filePath;
|
||||
}
|
||||
|
||||
// Preserve transparency or handle it for webp if needed
|
||||
imagealphablending($image, true);
|
||||
imagesavealpha($image, true);
|
||||
|
||||
$originalWidth = imagesx($image);
|
||||
$originalHeight = imagesy($image);
|
||||
|
||||
// Target dimensions: Hero bg size, max width 1920px
|
||||
$maxWidth = 1920;
|
||||
if ($originalWidth > $maxWidth) {
|
||||
$newWidth = $maxWidth;
|
||||
$newHeight = (int) (($originalHeight / $originalWidth) * $maxWidth);
|
||||
|
||||
$resizedImage = imagecreatetruecolor($newWidth, $newHeight);
|
||||
|
||||
// Preserve transparency
|
||||
imagealphablending($resizedImage, false);
|
||||
imagesavealpha($resizedImage, true);
|
||||
|
||||
// Resize
|
||||
imagecopyresampled($resizedImage, $image, 0, 0, 0, 0, $newWidth, $newHeight, $originalWidth, $originalHeight);
|
||||
imagedestroy($image);
|
||||
$image = $resizedImage;
|
||||
}
|
||||
|
||||
// Generate new webp path
|
||||
$pathInfo = pathinfo($filePath);
|
||||
$newWebpPath = $pathInfo['dirname'] . '/' . $pathInfo['filename'] . '.webp';
|
||||
$absoluteWebpPath = $disk->path($newWebpPath);
|
||||
|
||||
// Save as webp with 80% quality (excellent quality-to-size ratio)
|
||||
if (imagewebp($image, $absoluteWebpPath, 80)) {
|
||||
imagedestroy($image);
|
||||
|
||||
// If the file extension changed, delete the original file
|
||||
if ($newWebpPath !== $filePath) {
|
||||
$disk->delete($filePath);
|
||||
}
|
||||
|
||||
return $newWebpPath;
|
||||
}
|
||||
|
||||
imagedestroy($image);
|
||||
} catch (\Exception $e) {
|
||||
// Log error or fallback to original
|
||||
\Illuminate\Support\Facades\Log::error('Hero background image processing failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return $filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -418,24 +309,15 @@ class Setting extends Model
|
||||
return isset($this->attributes['value']) ? json_decode($this->attributes['value'], true) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an active setting record by key.
|
||||
*/
|
||||
public static function findActiveByKey(string $key): ?self
|
||||
{
|
||||
return static::query()
|
||||
->where('key', $key)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get setting by key
|
||||
*/
|
||||
public static function get(string $key, $default = null)
|
||||
{
|
||||
$setting = static::findActiveByKey($key);
|
||||
|
||||
$setting = static::where('key', $key)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
if (!$setting) {
|
||||
return $default;
|
||||
}
|
||||
@@ -487,16 +369,12 @@ class Setting extends Model
|
||||
*/
|
||||
public static function getGroup(string $group): array
|
||||
{
|
||||
return static::query()
|
||||
->where('group', $group)
|
||||
return static::where('group', $group)
|
||||
->where('is_active', true)
|
||||
->pluck('value', 'key')
|
||||
->map(function ($value, $key) {
|
||||
$setting = static::findActiveByKey($key);
|
||||
|
||||
return $setting
|
||||
? static::castValue($value, $setting->type)
|
||||
: $value;
|
||||
->map(function ($value, $key) use ($group) {
|
||||
$setting = static::where('key', $key)->first();
|
||||
return static::castValue($value, $setting->type);
|
||||
})
|
||||
->toArray();
|
||||
}
|
||||
|
||||
@@ -23,8 +23,6 @@ class User extends Authenticatable
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'avatar',
|
||||
'role',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\Blog;
|
||||
use App\Models\Setting;
|
||||
use App\Services\LinkedInService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class BlogObserver
|
||||
{
|
||||
/**
|
||||
* Handle the Blog "saved" event.
|
||||
*/
|
||||
public function saved(Blog $blog): void
|
||||
{
|
||||
// Only trigger if blog status is 'published'
|
||||
if ($blog->status !== 'published') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if auto-publish setting is enabled
|
||||
if (!Setting::get('linkedin_auto_publish_blogs', '1')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it was just published or status changed to published
|
||||
$wasJustPublished = $blog->wasRecentlyCreated || $blog->wasChanged('status');
|
||||
|
||||
if ($wasJustPublished) {
|
||||
try {
|
||||
$linkedInService = app(LinkedInService::class);
|
||||
|
||||
if ($linkedInService->isAuthorized()) {
|
||||
$title = $blog->title;
|
||||
$excerpt = $blog->excerpt ? strip_tags($blog->excerpt) : Str::limit(strip_tags($blog->content), 200);
|
||||
$url = url('/blog/' . $blog->slug);
|
||||
|
||||
$result = $linkedInService->sharePost($title, $excerpt, $url);
|
||||
|
||||
if ($result['success']) {
|
||||
Log::info("Blog [#{$blog->id}] LinkedIn'de otomatik paylaşıldı.", ['post_id' => $result['post_id'] ?? null]);
|
||||
} else {
|
||||
Log::warning("Blog [#{$blog->id}] LinkedIn paylaşımı başarısız: " . $result['message']);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error("BlogObserver LinkedIn auto-post error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\Blog;
|
||||
use App\Models\Page;
|
||||
use App\Observers\BlogObserver;
|
||||
use App\Observers\PageObserver;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
@@ -28,7 +26,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
// Page model için observer kaydet
|
||||
// Page model için observer kaydet
|
||||
Page::observe(PageObserver::class);
|
||||
Blog::observe(BlogObserver::class);
|
||||
|
||||
if ($this->app->environment('production') || $this->app->environment('staging')) {
|
||||
\Illuminate\Support\Facades\URL::forceScheme('https');
|
||||
|
||||
@@ -85,13 +85,6 @@ class AdminPanelProvider extends PanelProvider
|
||||
->sort(100); // En sonda görünsün
|
||||
})->toArray()
|
||||
])
|
||||
->navigationItems([
|
||||
\Filament\Navigation\NavigationItem::make('Admin Stajyer Paneli')
|
||||
->url('https://truncgil.com/stajyer/admin/panel')
|
||||
->openUrlInNewTab()
|
||||
->icon('heroicon-o-academic-cap')
|
||||
->sort(1000),
|
||||
])
|
||||
->assets([
|
||||
\Filament\Support\Assets\Css::make('citrus', resource_path('css/citrus.css')),
|
||||
]);
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class LinkedInService
|
||||
{
|
||||
protected string $clientId;
|
||||
protected string $clientSecret;
|
||||
protected string $organizationId;
|
||||
protected string $redirectUri;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->clientId = config('linkedin.client_id', '');
|
||||
$this->clientSecret = config('linkedin.client_secret', '');
|
||||
$this->organizationId = config('linkedin.organization_id', '35611757');
|
||||
$this->redirectUri = config('linkedin.redirect_uri', url('/admin/linkedin/callback'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate OAuth 2.0 Authorization URL for admin redirect
|
||||
*/
|
||||
public function getAuthorizationUrl(): string
|
||||
{
|
||||
$scopes = implode(' ', config('linkedin.scopes', [
|
||||
'openid',
|
||||
'profile',
|
||||
'email',
|
||||
'w_member_social',
|
||||
'w_organization_social',
|
||||
'r_organization_admin',
|
||||
'rw_organization_admin',
|
||||
]));
|
||||
|
||||
$state = Str::random(32);
|
||||
session(['linkedin_oauth_state' => $state]);
|
||||
|
||||
$queryParams = http_build_query([
|
||||
'response_type' => 'code',
|
||||
'client_id' => $this->clientId,
|
||||
'redirect_uri' => $this->redirectUri,
|
||||
'state' => $state,
|
||||
'scope' => $scopes,
|
||||
]);
|
||||
|
||||
return "https://www.linkedin.com/oauth/v2/authorization?" . $queryParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OAuth Callback and exchange authorization code for access token
|
||||
*/
|
||||
public function handleCallback(string $code): array
|
||||
{
|
||||
try {
|
||||
$response = Http::asForm()->post('https://www.linkedin.com/oauth/v2/accessToken', [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'redirect_uri' => $this->redirectUri,
|
||||
'client_id' => $this->clientId,
|
||||
'client_secret' => $this->clientSecret,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('LinkedIn OAuth Token Exchange Failed', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'LinkedIn Access Token alınamadı: ' . ($response->json('error_description') ?? $response->body()),
|
||||
];
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
$accessToken = $data['access_token'] ?? null;
|
||||
$expiresIn = $data['expires_in'] ?? 5184000; // Default 60 days in seconds
|
||||
|
||||
if (!$accessToken) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'LinkedIn Access Token yanıt içinde bulunamadı.',
|
||||
];
|
||||
}
|
||||
|
||||
$expiresAt = now()->addSeconds($expiresIn)->toDateTimeString();
|
||||
|
||||
// Save in Setting model
|
||||
Setting::updateOrCreate(
|
||||
['key' => 'linkedin_access_token'],
|
||||
['value' => $accessToken, 'type' => 'text', 'group' => 'social_media', 'label' => 'LinkedIn Access Token']
|
||||
);
|
||||
|
||||
Setting::updateOrCreate(
|
||||
['key' => 'linkedin_token_expires_at'],
|
||||
['value' => $expiresAt, 'type' => 'datetime', 'group' => 'social_media', 'label' => 'LinkedIn Token Expiration']
|
||||
);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'LinkedIn hesabınız başarıyla bağlandı! Access Token kaydedildi.',
|
||||
'expires_at' => $expiresAt,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('LinkedIn Callback Error: ' . $e->getMessage());
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Hata oluştu: ' . $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if valid LinkedIn Access Token exists
|
||||
*/
|
||||
public function isAuthorized(): bool
|
||||
{
|
||||
$token = Setting::get('linkedin_access_token');
|
||||
$expiresAt = Setting::get('linkedin_token_expires_at');
|
||||
|
||||
if (!$token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($expiresAt && now()->greaterThanOrEqualTo($expiresAt)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining days of Access Token
|
||||
*/
|
||||
public function getTokenExpirationStatus(): array
|
||||
{
|
||||
$expiresAt = Setting::get('linkedin_token_expires_at');
|
||||
|
||||
if (!$expiresAt) {
|
||||
return [
|
||||
'is_valid' => false,
|
||||
'message' => 'Yetkilendirme yapılmadı.',
|
||||
];
|
||||
}
|
||||
|
||||
$date = \Carbon\Carbon::parse($expiresAt);
|
||||
$diff = (int) round(now()->diffInDays($date, false));
|
||||
|
||||
if ($diff <= 0) {
|
||||
return [
|
||||
'is_valid' => false,
|
||||
'message' => 'Erişim anahtarının süresi doldu (' . $date->format('d.m.Y H:i') . '). Yeniden yetkilendirin.',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'is_valid' => true,
|
||||
'days_left' => $diff,
|
||||
'expires_at' => $date->format('d.m.Y H:i'),
|
||||
'message' => "Erişim anahtarı aktif. Kalan süre: {$diff} gün ({$date->format('d.m.Y H:i')}).",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Share a post to LinkedIn Company Page
|
||||
*/
|
||||
public function sharePost(string $title, string $text, ?string $url = null, ?string $imageUrl = null): array
|
||||
{
|
||||
if (!$this->isAuthorized()) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'LinkedIn yetkilendirmesi bulunamadı veya süresi dolmuş.',
|
||||
];
|
||||
}
|
||||
|
||||
$token = Setting::get('linkedin_access_token');
|
||||
$authorUrn = "urn:li:organization:{$this->organizationId}";
|
||||
|
||||
// Prepare UGC Post payload
|
||||
$shareCommentary = trim($title . "\n\n" . $text);
|
||||
if ($url) {
|
||||
$shareCommentary .= "\n\n" . $url;
|
||||
}
|
||||
|
||||
$mediaContent = [];
|
||||
if ($url) {
|
||||
$mediaItem = [
|
||||
'status' => 'READY',
|
||||
'originalUrl' => $url,
|
||||
'title' => [
|
||||
'text' => Str::limit($title, 200),
|
||||
],
|
||||
'description' => [
|
||||
'text' => Str::limit(strip_tags($text), 250),
|
||||
],
|
||||
];
|
||||
$mediaContent[] = $mediaItem;
|
||||
}
|
||||
|
||||
$shareContent = [
|
||||
'shareCommentary' => [
|
||||
'text' => $shareCommentary,
|
||||
],
|
||||
'shareMediaCategory' => !empty($mediaContent) ? 'ARTICLE' : 'NONE',
|
||||
];
|
||||
|
||||
if (!empty($mediaContent)) {
|
||||
$shareContent['media'] = $mediaContent;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'author' => $authorUrn,
|
||||
'lifecycleState' => 'PUBLISHED',
|
||||
'specificContent' => [
|
||||
'com.linkedin.ugc.ShareContent' => $shareContent,
|
||||
],
|
||||
'visibility' => [
|
||||
'com.linkedin.ugc.ShareProductVisibility' => 'PUBLIC',
|
||||
],
|
||||
];
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => 'Bearer ' . $token,
|
||||
'X-Restli-Protocol-Version' => '2.0.0',
|
||||
'Content-Type' => 'application/json',
|
||||
])->post('https://api.linkedin.com/v2/ugcPosts', $payload);
|
||||
|
||||
if ($response->successful()) {
|
||||
$postId = $response->header('x-restli-id') ?? $response->json('id');
|
||||
Log::info('LinkedIn post published successfully', [
|
||||
'organization_id' => $this->organizationId,
|
||||
'post_id' => $postId,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'LinkedIn gönderisi başarıyla paylaşıldı!',
|
||||
'post_id' => $postId,
|
||||
];
|
||||
}
|
||||
|
||||
Log::error('LinkedIn Share API Failed', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'LinkedIn gönderisi paylaşılamadı: ' . ($response->json('message') ?? $response->body()),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('LinkedIn Share Post Exception: ' . $e->getMessage());
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Gönderi paylaşılırken bir hata oluştu: ' . $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SpotifyService
|
||||
{
|
||||
protected ?string $accessToken = null;
|
||||
|
||||
public function __construct(
|
||||
protected ?string $clientId = null,
|
||||
protected ?string $clientSecret = null,
|
||||
protected ?string $artistId = null,
|
||||
) {
|
||||
$this->clientId = $clientId ?? config('services.spotify.client_id');
|
||||
$this->clientSecret = $clientSecret ?? config('services.spotify.client_secret');
|
||||
$this->artistId = $artistId ?? config('services.spotify.artist_id');
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return filled($this->clientId)
|
||||
&& filled($this->clientSecret)
|
||||
&& filled($this->artistId);
|
||||
}
|
||||
|
||||
public function getArtistId(): ?string
|
||||
{
|
||||
return $this->artistId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getArtistAlbums(string $includeGroups = 'album,single'): array
|
||||
{
|
||||
$this->authenticate();
|
||||
|
||||
$albums = [];
|
||||
$url = "https://api.spotify.com/v1/artists/{$this->artistId}/albums";
|
||||
$params = [
|
||||
'include_groups' => $includeGroups,
|
||||
'limit' => 50,
|
||||
'market' => config('services.spotify.market', 'TR'),
|
||||
];
|
||||
|
||||
do {
|
||||
$response = $this->client()->get($url, $params);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('Spotify artist albums fetch failed', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
throw new \RuntimeException($this->formatApiError(
|
||||
__('music_productions.spotify_albums_fetch_failed'),
|
||||
$response
|
||||
));
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
$items = $data['items'] ?? [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$albums[$item['id']] = $item;
|
||||
}
|
||||
|
||||
$url = $data['next'] ?? null;
|
||||
$params = [];
|
||||
} while ($url);
|
||||
|
||||
return array_values($albums);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function getAlbum(string $albumId): ?array
|
||||
{
|
||||
$this->authenticate();
|
||||
|
||||
$response = $this->client()->get("https://api.spotify.com/v1/albums/{$albumId}", [
|
||||
'market' => config('services.spotify.market', 'TR'),
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::warning('Spotify album fetch failed', [
|
||||
'album_id' => $albumId,
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
public function downloadCoverImage(?string $imageUrl, string $albumId): ?string
|
||||
{
|
||||
if (blank($imageUrl)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::timeout(30)->get($imageUrl);
|
||||
|
||||
if ($response->failed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$extension = $this->guessImageExtension($response->header('Content-Type'));
|
||||
$path = 'music-productions/covers/spotify-' . $albumId . '.' . $extension;
|
||||
|
||||
Storage::disk('public')->put($path, $response->body());
|
||||
|
||||
return $path;
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('Spotify cover download failed', [
|
||||
'album_id' => $albumId,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function buildDefaultContent(array $album): string
|
||||
{
|
||||
$title = e($album['name'] ?? '');
|
||||
$spotifyUrl = e($album['external_urls']['spotify'] ?? '');
|
||||
$artistNames = collect($album['artists'] ?? [])
|
||||
->pluck('name')
|
||||
->filter()
|
||||
->implode(', ');
|
||||
$artistNames = e($artistNames);
|
||||
$albumType = e($album['album_type'] ?? 'album');
|
||||
$totalTracks = (int) ($album['total_tracks'] ?? 0);
|
||||
|
||||
$listenLabel = e(__('music_productions.spotify_listen_on_spotify'));
|
||||
|
||||
return <<<HTML
|
||||
<p><strong>{$title}</strong> — {$artistNames}</p>
|
||||
<p>{$albumType} · {$totalTracks} {$this->trackLabel($totalTracks)}</p>
|
||||
<p><a href="{$spotifyUrl}" target="_blank" rel="noopener noreferrer">{$listenLabel}</a></p>
|
||||
HTML;
|
||||
}
|
||||
|
||||
protected function trackLabel(int $count): string
|
||||
{
|
||||
return $count === 1
|
||||
? __('music_productions.spotify_track_singular')
|
||||
: __('music_productions.spotify_track_plural');
|
||||
}
|
||||
|
||||
protected function authenticate(): void
|
||||
{
|
||||
if ($this->accessToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->isConfigured()) {
|
||||
throw new \RuntimeException(__('music_productions.spotify_not_configured'));
|
||||
}
|
||||
|
||||
$response = Http::asForm()
|
||||
->withHeaders([
|
||||
'Authorization' => 'Basic ' . base64_encode($this->clientId . ':' . $this->clientSecret),
|
||||
])
|
||||
->post('https://accounts.spotify.com/api/token', [
|
||||
'grant_type' => 'client_credentials',
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('Spotify access token request failed', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
throw new \RuntimeException($this->formatApiError(
|
||||
__('music_productions.spotify_token_failed'),
|
||||
$response
|
||||
));
|
||||
}
|
||||
|
||||
$this->accessToken = $response->json('access_token');
|
||||
}
|
||||
|
||||
protected function client(): PendingRequest
|
||||
{
|
||||
return Http::withToken($this->accessToken)
|
||||
->acceptJson()
|
||||
->timeout(30);
|
||||
}
|
||||
|
||||
protected function formatApiError(string $fallback, \Illuminate\Http\Client\Response $response): string
|
||||
{
|
||||
$body = trim($response->body());
|
||||
$message = $body;
|
||||
|
||||
if (str_starts_with($body, '{')) {
|
||||
$message = $response->json('error.message')
|
||||
?? $response->json('error_description')
|
||||
?? $body;
|
||||
}
|
||||
|
||||
if (blank($message)) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
if ($response->status() === 403 && str_contains(strtolower($message), 'premium')) {
|
||||
return __('music_productions.spotify_premium_required', ['detail' => $message]);
|
||||
}
|
||||
|
||||
return $fallback . ' (HTTP ' . $response->status() . ': ' . $message . ')';
|
||||
}
|
||||
|
||||
protected function guessImageExtension(?string $contentType): string
|
||||
{
|
||||
return match ($contentType) {
|
||||
'image/png' => 'png',
|
||||
'image/webp' => 'webp',
|
||||
'image/gif' => 'gif',
|
||||
default => 'jpg',
|
||||
};
|
||||
}
|
||||
|
||||
public static function uniqueSlug(string $title, ?int $ignoreId = null): string
|
||||
{
|
||||
$baseSlug = Str::slug($title) ?: 'spotify-album';
|
||||
$slug = $baseSlug;
|
||||
$counter = 1;
|
||||
|
||||
while (
|
||||
\App\Models\MusicProduction::withTrashed()
|
||||
->when($ignoreId, fn ($query) => $query->where('id', '!=', $ignoreId))
|
||||
->where('slug', $slug)
|
||||
->exists()
|
||||
) {
|
||||
$slug = $baseSlug . '-' . $counter;
|
||||
$counter++;
|
||||
}
|
||||
|
||||
return $slug;
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,7 @@ namespace App\Services;
|
||||
use App\Models\HeaderTemplate;
|
||||
use App\Models\SectionTemplate;
|
||||
use App\Models\FooterTemplate;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Filament\Forms\Components\{
|
||||
TextInput, Textarea, Select, Checkbox, CheckboxList,
|
||||
Radio, Toggle, ToggleButtons, DateTimePicker, DatePicker,
|
||||
@@ -297,15 +294,11 @@ class TemplateService
|
||||
*/
|
||||
protected static function getSelectOptions(string $fieldName): array
|
||||
{
|
||||
$defaults = [
|
||||
return config("template-options.{$fieldName}", [
|
||||
'option_1' => __('Option 1'),
|
||||
'option_2' => __('Option 2'),
|
||||
'option_3' => __('Option 3'),
|
||||
];
|
||||
|
||||
$options = config("template-options.{$fieldName}", $defaults);
|
||||
|
||||
return is_array($options) ? $options : $defaults;
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -383,15 +376,6 @@ class TemplateService
|
||||
}
|
||||
|
||||
// Handle custom blade components: {custom.component_name} or {custom.component-name}
|
||||
if (str_contains($html, '{custom.language-selector}')) {
|
||||
$desktopLanguage = view('components.custom.language-selector', [
|
||||
'variant' => 'default',
|
||||
])->render();
|
||||
|
||||
$html = str_replace('{custom.language-selector}', $desktopLanguage, $html);
|
||||
$html = self::injectOffcanvasLanguageSelector($html);
|
||||
}
|
||||
|
||||
preg_match_all('/\{custom\.([a-z][a-z_-]*)\}/i', $html, $customMatches);
|
||||
if (!empty($customMatches[0])) {
|
||||
foreach ($customMatches[0] as $index => $fullMatch) {
|
||||
@@ -399,12 +383,8 @@ class TemplateService
|
||||
if ($componentName) {
|
||||
// Normalize component name to lowercase for consistent file system access
|
||||
$componentName = strtolower($componentName);
|
||||
|
||||
if ($componentName === 'language-selector') {
|
||||
continue;
|
||||
}
|
||||
$viewPath = "components.custom.{$componentName}";
|
||||
if (View::exists($viewPath)) {
|
||||
if (view()->exists($viewPath)) {
|
||||
try {
|
||||
$renderedComponent = view($viewPath)->render();
|
||||
// Ensure rendered component is properly formatted
|
||||
@@ -412,7 +392,7 @@ class TemplateService
|
||||
$html = str_replace($fullMatch, $renderedComponent, $html);
|
||||
} catch (\Exception $e) {
|
||||
// Log the error for debugging
|
||||
Log::error("Custom component render error: {$viewPath}", [
|
||||
\Log::error("Custom component render error: {$viewPath}", [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
@@ -522,10 +502,10 @@ class TemplateService
|
||||
// Handle setting.* placeholders - get value from Setting model
|
||||
if ($type === 'setting' && $field) {
|
||||
$value = setting($field, '');
|
||||
|
||||
|
||||
// Get setting type to determine if it's a file/image
|
||||
$settingModel = Setting::findActiveByKey($field);
|
||||
if ($settingModel && in_array($settingModel->type, ['file', 'image'], true)) {
|
||||
$settingModel = \App\Models\Setting::where('key', $field)->where('is_active', true)->first();
|
||||
if ($settingModel && in_array($settingModel->type, ['file', 'image'])) {
|
||||
// For file/image settings, use file type for formatting
|
||||
$type = 'file';
|
||||
}
|
||||
@@ -680,32 +660,5 @@ class TemplateService
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject mobile language selector at the top of the first offcanvas menu body.
|
||||
*/
|
||||
protected static function injectOffcanvasLanguageSelector(string $html): string
|
||||
{
|
||||
if (str_contains($html, 'offcanvas-language-mobile-wrap')) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
$mobileLanguage = view('components.custom.language-selector', [
|
||||
'variant' => 'offcanvas',
|
||||
'class' => 'offcanvas-language-mobile',
|
||||
])->render();
|
||||
|
||||
$injection = '<div class="xl:!hidden lg:!hidden pb-4 mb-2 border-b border-white/10 offcanvas-language-mobile-wrap">'
|
||||
. $mobileLanguage
|
||||
. '</div>';
|
||||
|
||||
$pattern = '/(<div[^>]*class="[^"]*offcanvas-body[^"]*"[^>]*>)/i';
|
||||
|
||||
if (preg_match($pattern, $html)) {
|
||||
return preg_replace($pattern, '$1' . $injection, $html, 1);
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,489 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class YouTubeService
|
||||
{
|
||||
protected ?string $resolvedTopicChannelId = null;
|
||||
|
||||
protected ?string $resolvedReleasesSource = null;
|
||||
|
||||
public function __construct(
|
||||
protected ?string $apiKey = null,
|
||||
protected ?string $channelId = null,
|
||||
protected ?string $topicChannelId = null,
|
||||
protected ?string $playlistId = null,
|
||||
) {
|
||||
$this->apiKey = $apiKey ?? config('services.youtube.api_key');
|
||||
$this->channelId = $channelId ?? config('services.youtube.channel_id');
|
||||
$this->topicChannelId = $topicChannelId ?? config('services.youtube.topic_channel_id');
|
||||
$this->playlistId = $playlistId ?? config('services.youtube.playlist_id');
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return filled($this->apiKey)
|
||||
&& (filled($this->channelId) || filled($this->topicChannelId) || filled($this->playlistId));
|
||||
}
|
||||
|
||||
public function getReleasesSourceLabel(): string
|
||||
{
|
||||
$this->resolveReleasesPlaylistId();
|
||||
|
||||
return $this->resolvedReleasesSource ?? '-';
|
||||
}
|
||||
|
||||
public function resolveReleasesPlaylistId(): ?string
|
||||
{
|
||||
if (filled($this->playlistId)) {
|
||||
$this->resolvedReleasesSource = 'playlist:' . $this->playlistId;
|
||||
|
||||
return $this->playlistId;
|
||||
}
|
||||
|
||||
$topicChannelId = $this->resolveTopicChannelId();
|
||||
|
||||
if (filled($topicChannelId)) {
|
||||
$playlistId = $this->channelToUploadsPlaylistId($topicChannelId);
|
||||
$this->resolvedReleasesSource = 'topic:' . $topicChannelId;
|
||||
|
||||
return $playlistId;
|
||||
}
|
||||
|
||||
if (filled($this->channelId)) {
|
||||
Log::warning('YouTube Topic channel not found, falling back to main channel uploads', [
|
||||
'channel_id' => $this->channelId,
|
||||
]);
|
||||
|
||||
$playlistId = $this->channelToUploadsPlaylistId($this->channelId);
|
||||
$this->resolvedReleasesSource = 'main:' . $this->channelId;
|
||||
|
||||
return $playlistId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function resolveTopicChannelId(): ?string
|
||||
{
|
||||
if ($this->resolvedTopicChannelId !== null) {
|
||||
return $this->resolvedTopicChannelId ?: null;
|
||||
}
|
||||
|
||||
if (filled($this->topicChannelId)) {
|
||||
$this->resolvedTopicChannelId = $this->topicChannelId;
|
||||
|
||||
return $this->resolvedTopicChannelId;
|
||||
}
|
||||
|
||||
if (blank($this->channelId)) {
|
||||
$this->resolvedTopicChannelId = '';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->resolvedTopicChannelId = $this->discoverTopicChannelId($this->channelId) ?? '';
|
||||
|
||||
return $this->resolvedTopicChannelId ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getReleases(): array
|
||||
{
|
||||
$playlistId = $this->resolveReleasesPlaylistId();
|
||||
|
||||
if (blank($playlistId)) {
|
||||
throw new \RuntimeException(__('music_productions.youtube_playlist_not_resolved'));
|
||||
}
|
||||
|
||||
$releases = [];
|
||||
$pageToken = null;
|
||||
$distributorFilter = config('services.youtube.distributor_filter');
|
||||
|
||||
do {
|
||||
$params = [
|
||||
'key' => $this->apiKey,
|
||||
'playlistId' => $playlistId,
|
||||
'part' => 'snippet',
|
||||
'maxResults' => 50,
|
||||
];
|
||||
|
||||
if ($pageToken) {
|
||||
$params['pageToken'] = $pageToken;
|
||||
}
|
||||
|
||||
$response = $this->client()->get('https://www.googleapis.com/youtube/v3/playlistItems', $params);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('YouTube releases fetch failed', [
|
||||
'playlist_id' => $playlistId,
|
||||
'source' => $this->resolvedReleasesSource,
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
throw new \RuntimeException($this->formatApiError(
|
||||
__('music_productions.youtube_releases_fetch_failed'),
|
||||
$response
|
||||
));
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
$items = $data['items'] ?? [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$normalized = $this->normalizeReleaseItem($item, $distributorFilter);
|
||||
|
||||
if ($normalized) {
|
||||
$releases[$normalized['video_id']] = $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
$pageToken = $data['nextPageToken'] ?? null;
|
||||
} while ($pageToken);
|
||||
|
||||
return array_values($releases);
|
||||
}
|
||||
|
||||
protected function discoverTopicChannelId(string $mainChannelId): ?string
|
||||
{
|
||||
$channel = $this->getChannel($mainChannelId);
|
||||
|
||||
if (! $channel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$artistTitle = trim($channel['snippet']['title'] ?? '');
|
||||
|
||||
if (blank($artistTitle)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$expectedTopicTitle = $artistTitle . ' - Topic';
|
||||
$mainThumbnail = $channel['snippet']['thumbnails']['default']['url'] ?? null;
|
||||
|
||||
$response = $this->client()->get('https://www.googleapis.com/youtube/v3/search', [
|
||||
'key' => $this->apiKey,
|
||||
'part' => 'snippet',
|
||||
'type' => 'channel',
|
||||
'q' => $expectedTopicTitle,
|
||||
'maxResults' => 10,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::warning('YouTube topic channel search failed', [
|
||||
'channel_id' => $mainChannelId,
|
||||
'query' => $expectedTopicTitle,
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$topicCandidates = [];
|
||||
|
||||
foreach ($response->json('items') ?? [] as $item) {
|
||||
$channelId = $item['snippet']['channelId'] ?? $item['id']['channelId'] ?? null;
|
||||
$title = trim($item['snippet']['title'] ?? '');
|
||||
|
||||
if (blank($channelId) || $channelId === $mainChannelId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->titlesMatch($title, $expectedTopicTitle)) {
|
||||
Log::info('YouTube topic channel resolved by exact title match', [
|
||||
'main_channel_id' => $mainChannelId,
|
||||
'topic_channel_id' => $channelId,
|
||||
'title' => $title,
|
||||
]);
|
||||
|
||||
return $channelId;
|
||||
}
|
||||
|
||||
if (str_ends_with($title, ' - Topic')) {
|
||||
$topicCandidates[] = [
|
||||
'channel_id' => $channelId,
|
||||
'title' => $title,
|
||||
'thumbnail' => $item['snippet']['thumbnails']['default']['url'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($topicCandidates as $candidate) {
|
||||
if ($this->thumbnailsMatch($mainThumbnail, $candidate['thumbnail'])) {
|
||||
Log::info('YouTube topic channel resolved by avatar match', [
|
||||
'main_channel_id' => $mainChannelId,
|
||||
'topic_channel_id' => $candidate['channel_id'],
|
||||
'title' => $candidate['title'],
|
||||
]);
|
||||
|
||||
return $candidate['channel_id'];
|
||||
}
|
||||
}
|
||||
|
||||
Log::warning('YouTube topic channel could not be resolved safely', [
|
||||
'main_channel_id' => $mainChannelId,
|
||||
'expected_title' => $expectedTopicTitle,
|
||||
'candidates' => collect($topicCandidates)->pluck('title')->all(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function titlesMatch(string $left, string $right): bool
|
||||
{
|
||||
return mb_strtolower(trim($left)) === mb_strtolower(trim($right));
|
||||
}
|
||||
|
||||
protected function thumbnailsMatch(?string $left, ?string $right): bool
|
||||
{
|
||||
if (blank($left) || blank($right)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->normalizeThumbnailUrl($left) === $this->normalizeThumbnailUrl($right);
|
||||
}
|
||||
|
||||
protected function normalizeThumbnailUrl(string $url): string
|
||||
{
|
||||
$path = parse_url($url, PHP_URL_PATH) ?: $url;
|
||||
|
||||
return preg_replace('/=s\d+-/', '=s88-', $path) ?? $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
protected function getChannel(string $channelId): ?array
|
||||
{
|
||||
$response = $this->client()->get('https://www.googleapis.com/youtube/v3/channels', [
|
||||
'key' => $this->apiKey,
|
||||
'id' => $channelId,
|
||||
'part' => 'snippet,contentDetails',
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $response->json('items.0');
|
||||
}
|
||||
|
||||
protected function channelToUploadsPlaylistId(string $channelId): string
|
||||
{
|
||||
if (str_starts_with($channelId, 'UU') || str_starts_with($channelId, 'PL')) {
|
||||
return $channelId;
|
||||
}
|
||||
|
||||
if (str_starts_with($channelId, 'UC')) {
|
||||
return 'UU' . substr($channelId, 2);
|
||||
}
|
||||
|
||||
return $channelId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $item
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
protected function normalizeReleaseItem(array $item, ?string $distributorFilter = null): ?array
|
||||
{
|
||||
$snippet = $item['snippet'] ?? [];
|
||||
$videoId = $snippet['resourceId']['videoId'] ?? null;
|
||||
$title = trim($snippet['title'] ?? '');
|
||||
$description = trim($snippet['description'] ?? '');
|
||||
|
||||
if (blank($videoId) || blank($title)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (in_array($title, ['Private video', 'Deleted video', 'Gizli video', 'Silinmiş video'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (filled($distributorFilter) && ! str_contains($description, $distributorFilter)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$thumbnails = $snippet['thumbnails'] ?? [];
|
||||
$coverUrl = $thumbnails['maxres']['url']
|
||||
?? $thumbnails['standard']['url']
|
||||
?? $thumbnails['high']['url']
|
||||
?? $thumbnails['medium']['url']
|
||||
?? $thumbnails['default']['url']
|
||||
?? null;
|
||||
|
||||
return [
|
||||
'video_id' => $videoId,
|
||||
'title' => $title,
|
||||
'description' => $description,
|
||||
'cover_url' => $coverUrl,
|
||||
'published_at' => $snippet['publishedAt'] ?? null,
|
||||
'release_date' => $this->parseReleaseDate($description, $snippet['publishedAt'] ?? null),
|
||||
'youtube_url' => 'https://www.youtube.com/watch?v=' . $videoId,
|
||||
];
|
||||
}
|
||||
|
||||
public function parseReleaseDate(string $description, ?string $publishedAt = null): ?string
|
||||
{
|
||||
if (preg_match('/Released on:\s*(\d{4}-\d{2}-\d{2})/i', $description, $matches)) {
|
||||
return Carbon::parse($matches[1])->toDateString();
|
||||
}
|
||||
|
||||
if (preg_match('/Release date:\s*(\d{4}-\d{2}-\d{2})/i', $description, $matches)) {
|
||||
return Carbon::parse($matches[1])->toDateString();
|
||||
}
|
||||
|
||||
if (filled($publishedAt)) {
|
||||
return Carbon::parse($publishedAt)->toDateString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function downloadCoverImage(?string $imageUrl, string $videoId): ?string
|
||||
{
|
||||
if (blank($imageUrl)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::timeout(30)->get($imageUrl);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::warning('YouTube cover image fetch HTTP request failed', [
|
||||
'video_id' => $videoId,
|
||||
'url' => $imageUrl,
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
$extension = $this->guessImageExtension($response->header('Content-Type'));
|
||||
$path = 'music-productions/covers/youtube-' . $videoId . '.' . $extension;
|
||||
|
||||
$absolutePath = Storage::disk('public')->path($path);
|
||||
$directory = dirname($absolutePath);
|
||||
|
||||
if (!is_dir($directory)) {
|
||||
@mkdir($directory, 0775, true);
|
||||
}
|
||||
|
||||
if (is_dir($directory) && !is_writable($directory)) {
|
||||
Log::error('YouTube cover directory is not writable. Check folder ownership and permissions.', [
|
||||
'directory' => $directory,
|
||||
'owner' => function_exists('posix_getpwuid') ? posix_getpwuid(fileowner($directory))['name'] : fileowner($directory),
|
||||
'perms' => substr(sprintf('%o', fileperms($directory)), -4),
|
||||
]);
|
||||
}
|
||||
|
||||
$stored = Storage::disk('public')->put($path, $response->body());
|
||||
|
||||
if (!$stored) {
|
||||
Log::error('Failed to store YouTube cover image via storage disk.', [
|
||||
'path' => $path,
|
||||
'video_id' => $videoId,
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
return $path;
|
||||
} catch (\Throwable $exception) {
|
||||
Log::error('YouTube cover download failed with exception', [
|
||||
'video_id' => $videoId,
|
||||
'message' => $exception->getMessage(),
|
||||
'trace' => $exception->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function buildDefaultContent(array $release): string
|
||||
{
|
||||
$title = e($release['title'] ?? '');
|
||||
$youtubeUrl = e($release['youtube_url'] ?? '');
|
||||
$description = trim($release['description'] ?? '');
|
||||
$listenLabel = e(__('music_productions.youtube_watch_on_youtube'));
|
||||
|
||||
$descriptionHtml = filled($description)
|
||||
? '<p>' . nl2br(e($description)) . '</p>'
|
||||
: '';
|
||||
|
||||
return <<<HTML
|
||||
<p><strong>{$title}</strong></p>
|
||||
{$descriptionHtml}
|
||||
<p><a href="{$youtubeUrl}" target="_blank" rel="noopener noreferrer">{$listenLabel}</a></p>
|
||||
HTML;
|
||||
}
|
||||
|
||||
protected function client(): PendingRequest
|
||||
{
|
||||
$request = Http::acceptJson()->timeout(30);
|
||||
|
||||
$referer = config('services.youtube.api_referer');
|
||||
|
||||
if (filled($referer)) {
|
||||
$request = $request->withHeaders([
|
||||
'Referer' => rtrim($referer, '/') . '/',
|
||||
]);
|
||||
}
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
protected function formatApiError(string $fallback, \Illuminate\Http\Client\Response $response): string
|
||||
{
|
||||
$message = $response->json('error.message') ?? trim($response->body());
|
||||
|
||||
if (blank($message)) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
if ($response->status() === 403 && str_contains(strtolower($message), 'referer')) {
|
||||
return __('music_productions.youtube_referrer_blocked', [
|
||||
'detail' => $message,
|
||||
]);
|
||||
}
|
||||
|
||||
return $fallback . ' (HTTP ' . $response->status() . ': ' . $message . ')';
|
||||
}
|
||||
|
||||
protected function guessImageExtension(?string $contentType): string
|
||||
{
|
||||
return match ($contentType) {
|
||||
'image/png' => 'png',
|
||||
'image/webp' => 'webp',
|
||||
'image/gif' => 'gif',
|
||||
default => 'jpg',
|
||||
};
|
||||
}
|
||||
|
||||
public static function uniqueSlug(string $title, ?int $ignoreId = null): string
|
||||
{
|
||||
$baseSlug = Str::slug($title) ?: 'youtube-release';
|
||||
$slug = $baseSlug;
|
||||
$counter = 1;
|
||||
|
||||
while (
|
||||
\App\Models\MusicProduction::withTrashed()
|
||||
->when($ignoreId, fn ($query) => $query->where('id', '!=', $ignoreId))
|
||||
->where('slug', $slug)
|
||||
->exists()
|
||||
) {
|
||||
$slug = $baseSlug . '-' . $counter;
|
||||
$counter++;
|
||||
}
|
||||
|
||||
return $slug;
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Blog;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class BlogStructuredData extends StructuredData
|
||||
{
|
||||
public static function forIndex(LengthAwarePaginator $posts, string $pageUrl): array
|
||||
{
|
||||
$pageName = __('blog.meta-index-title');
|
||||
$pageDescription = __('blog.meta-index-description');
|
||||
|
||||
$itemListElements = [];
|
||||
$offset = ($posts->currentPage() - 1) * $posts->perPage();
|
||||
|
||||
foreach ($posts as $index => $post) {
|
||||
$itemListElements[] = self::listItem(
|
||||
$offset + $index + 1,
|
||||
self::postSummarySchema($post),
|
||||
);
|
||||
}
|
||||
|
||||
return self::wrap([
|
||||
self::websiteNode(),
|
||||
self::organizationNode(),
|
||||
self::webPageNode($pageUrl, $pageName, $pageDescription, [
|
||||
'@type' => 'CollectionPage',
|
||||
'mainEntity' => ['@id' => $pageUrl . '#itemlist'],
|
||||
]),
|
||||
self::breadcrumbNode($pageUrl, [
|
||||
['name' => __('blog.breadcrumb_home'), 'item' => self::siteUrl()],
|
||||
['name' => $pageName, 'item' => $pageUrl],
|
||||
]),
|
||||
[
|
||||
'@type' => 'ItemList',
|
||||
'@id' => $pageUrl . '#itemlist',
|
||||
'name' => $pageName,
|
||||
'numberOfItems' => $posts->total(),
|
||||
'itemListElement' => $itemListElements,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public static function forShow(Blog $post, string $pageUrl): array
|
||||
{
|
||||
$title = $post->translate('title');
|
||||
$description = Str::limit(strip_tags((string) $post->translate('excerpt')), 160);
|
||||
$imageUrl = $post->featured_image_url ?: self::defaultImageUrl();
|
||||
$publishedAt = $post->published_at ?? $post->created_at;
|
||||
|
||||
$blogPosting = [
|
||||
'@type' => 'BlogPosting',
|
||||
'@id' => $pageUrl . '#article',
|
||||
'mainEntityOfPage' => ['@id' => $pageUrl . '#webpage'],
|
||||
'headline' => $title,
|
||||
'description' => $description,
|
||||
'image' => [$imageUrl],
|
||||
'inLanguage' => app()->getLocale(),
|
||||
'author' => [
|
||||
'@type' => 'Person',
|
||||
'name' => $post->author_name,
|
||||
],
|
||||
'publisher' => self::publisherNode(),
|
||||
'datePublished' => $publishedAt->toIso8601String(),
|
||||
'dateModified' => $post->updated_at->toIso8601String(),
|
||||
'url' => $pageUrl,
|
||||
];
|
||||
|
||||
$content = strip_tags((string) $post->translate('content'));
|
||||
if ($content !== '') {
|
||||
$blogPosting['articleBody'] = Str::limit($content, 5000);
|
||||
}
|
||||
|
||||
return self::wrap([
|
||||
self::websiteNode(),
|
||||
self::organizationNode(),
|
||||
self::webPageNode($pageUrl, $title, $description, [
|
||||
'mainEntity' => ['@id' => $pageUrl . '#article'],
|
||||
'dateModified' => $post->updated_at->toIso8601String(),
|
||||
]),
|
||||
self::breadcrumbNode($pageUrl, [
|
||||
['name' => __('blog.breadcrumb_home'), 'item' => self::siteUrl()],
|
||||
['name' => __('blog.meta-index-title'), 'item' => route('blog.index')],
|
||||
['name' => $title, 'item' => $pageUrl],
|
||||
]),
|
||||
$blogPosting,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected static function postSummarySchema(Blog $post): array
|
||||
{
|
||||
$schema = [
|
||||
'@type' => 'BlogPosting',
|
||||
'headline' => $post->translate('title'),
|
||||
'url' => route('blog.show', $post->slug),
|
||||
];
|
||||
|
||||
if ($post->featured_image_url) {
|
||||
$schema['image'] = [$post->featured_image_url];
|
||||
}
|
||||
|
||||
$publishedAt = $post->published_at ?? $post->created_at;
|
||||
if ($publishedAt) {
|
||||
$schema['datePublished'] = $publishedAt->toIso8601String();
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected static function publisherNode(): array
|
||||
{
|
||||
$publisher = [
|
||||
'@type' => 'Organization',
|
||||
'@id' => self::siteUrl() . '#organization',
|
||||
'name' => self::siteName(),
|
||||
];
|
||||
|
||||
$logo = setting('site_logo');
|
||||
if ($logo) {
|
||||
$logoUrl = str_starts_with($logo, 'http') ? $logo : asset('storage/' . ltrim($logo, '/'));
|
||||
$publisher['logo'] = [
|
||||
'@type' => 'ImageObject',
|
||||
'url' => $logoUrl,
|
||||
];
|
||||
}
|
||||
|
||||
return $publisher;
|
||||
}
|
||||
|
||||
protected static function defaultImageUrl(): string
|
||||
{
|
||||
$defaultImage = setting('default_meta_image');
|
||||
|
||||
if ($defaultImage) {
|
||||
return str_starts_with($defaultImage, 'http') ? $defaultImage : asset($defaultImage);
|
||||
}
|
||||
|
||||
return asset('assets/img/logo.png');
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\MusicProduction;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class MusicProductionStructuredData extends StructuredData
|
||||
{
|
||||
public static function forIndex(LengthAwarePaginator $productions, string $pageUrl): array
|
||||
{
|
||||
$pageName = __('music_productions.meta-index-title');
|
||||
$pageDescription = __('music_productions.meta-index-description');
|
||||
|
||||
$itemListElements = [];
|
||||
$offset = ($productions->currentPage() - 1) * $productions->perPage();
|
||||
|
||||
foreach ($productions as $index => $production) {
|
||||
$itemListElements[] = self::listItem(
|
||||
$offset + $index + 1,
|
||||
self::productionSchema($production),
|
||||
);
|
||||
}
|
||||
|
||||
return self::wrap([
|
||||
self::websiteNode(route('music-productions.index') . '?q={search_term_string}'),
|
||||
self::organizationNode(),
|
||||
self::webPageNode($pageUrl, $pageName, $pageDescription, [
|
||||
'@type' => 'CollectionPage',
|
||||
'mainEntity' => ['@id' => $pageUrl . '#itemlist'],
|
||||
]),
|
||||
self::breadcrumbNode($pageUrl, [
|
||||
['name' => __('music_productions.breadcrumb_home'), 'item' => self::siteUrl()],
|
||||
['name' => __('music_productions.nav-music-productions'), 'item' => route('music-productions.index')],
|
||||
]),
|
||||
[
|
||||
'@type' => 'ItemList',
|
||||
'@id' => $pageUrl . '#itemlist',
|
||||
'name' => $pageName,
|
||||
'numberOfItems' => $productions->total(),
|
||||
'itemListElement' => $itemListElements,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public static function forShow(MusicProduction $production, string $pageUrl): array
|
||||
{
|
||||
$title = $production->translate('title');
|
||||
$description = Str::limit(strip_tags((string) $production->translate('content')), 160);
|
||||
|
||||
$webPageExtra = [
|
||||
'mainEntity' => ['@id' => $pageUrl . '#production'],
|
||||
];
|
||||
|
||||
if ($production->updated_at) {
|
||||
$webPageExtra['dateModified'] = $production->updated_at->toIso8601String();
|
||||
}
|
||||
|
||||
return self::wrap([
|
||||
self::websiteNode(route('music-productions.index') . '?q={search_term_string}'),
|
||||
self::organizationNode(),
|
||||
self::webPageNode($pageUrl, $title, $description, $webPageExtra),
|
||||
self::breadcrumbNode($pageUrl, [
|
||||
['name' => __('music_productions.breadcrumb_home'), 'item' => self::siteUrl()],
|
||||
['name' => __('music_productions.nav-music-productions'), 'item' => route('music-productions.index')],
|
||||
['name' => $title, 'item' => $pageUrl],
|
||||
]),
|
||||
array_merge(
|
||||
['@id' => $pageUrl . '#production'],
|
||||
self::productionSchema($production),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected static function productionSchema(MusicProduction $production): array
|
||||
{
|
||||
$schema = [
|
||||
'@type' => filled($production->spotify_album_id) ? 'MusicAlbum' : 'CreativeWork',
|
||||
'name' => $production->translate('title'),
|
||||
'url' => route('music-productions.show', $production->slug),
|
||||
];
|
||||
|
||||
if ($production->cover_image_url) {
|
||||
$schema['image'] = [$production->cover_image_url];
|
||||
}
|
||||
|
||||
$content = strip_tags((string) $production->translate('content'));
|
||||
if ($content !== '') {
|
||||
$schema['description'] = Str::limit($content, 300);
|
||||
}
|
||||
|
||||
if ($production->production_date) {
|
||||
$schema['datePublished'] = $production->production_date->toIso8601String();
|
||||
}
|
||||
|
||||
$clientName = $production->translate('client_name');
|
||||
if ($clientName) {
|
||||
$schema['creator'] = [
|
||||
'@type' => 'Organization',
|
||||
'name' => $clientName,
|
||||
];
|
||||
}
|
||||
|
||||
$sameAs = array_values(array_filter([
|
||||
$production->spotify_url,
|
||||
$production->youtube_url,
|
||||
]));
|
||||
|
||||
if ($sameAs !== []) {
|
||||
$schema['sameAs'] = $sameAs;
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user