diff --git a/.env.example b/.env.example
index d2347de..2914a26 100644
--- a/.env.example
+++ b/.env.example
@@ -78,6 +78,12 @@ YOUTUBE_CHANNEL_ID=
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
diff --git a/app/Console/Commands/PublishScheduledLinkedInPosts.php b/app/Console/Commands/PublishScheduledLinkedInPosts.php
new file mode 100644
index 0000000..de98bef
--- /dev/null
+++ b/app/Console/Commands/PublishScheduledLinkedInPosts.php
@@ -0,0 +1,55 @@
+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;
+ }
+}
diff --git a/app/Filament/Admin/Pages/LinkedInSettings.php b/app/Filament/Admin/Pages/LinkedInSettings.php
new file mode 100644
index 0000000..d6681c5
--- /dev/null
+++ b/app/Filament/Admin/Pages/LinkedInSettings.php
@@ -0,0 +1,214 @@
+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(Form $form): Form
+ {
+ $linkedInService = app(LinkedInService::class);
+ $status = $linkedInService->getTokenExpirationStatus();
+
+ $statusBadgeHtml = $status['is_valid']
+ ? '
+
+ Bağlı ve Aktif
+ '
+ : '
+
+ Bağlantı Yok / Süresi Dolmuş
+ ';
+
+ $statusDetailsHtml = '
+
+
Açıklama: ' . e($status['message']) . '
+
Client ID: ' . e(config('linkedin.client_id')) . '
+
Organization ID: ' . e(config('linkedin.organization_id')) . '
+
Redirect URI: ' . e(config('linkedin.redirect_uri')) . '
+
';
+
+ return $form->schema([
+ 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();
+ }
+ }
+}
diff --git a/app/Http/Controllers/Admin/LinkedInController.php b/app/Http/Controllers/Admin/LinkedInController.php
new file mode 100644
index 0000000..3a78421
--- /dev/null
+++ b/app/Http/Controllers/Admin/LinkedInController.php
@@ -0,0 +1,54 @@
+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']);
+ }
+}
diff --git a/app/Observers/BlogObserver.php b/app/Observers/BlogObserver.php
new file mode 100644
index 0000000..a1aa61d
--- /dev/null
+++ b/app/Observers/BlogObserver.php
@@ -0,0 +1,53 @@
+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());
+ }
+ }
+ }
+}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 4ac75a5..1af8fec 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -2,7 +2,9 @@
namespace App\Providers;
+use App\Models\Blog;
use App\Models\Page;
+use App\Observers\BlogObserver;
use App\Observers\PageObserver;
use Illuminate\Support\ServiceProvider;
@@ -26,6 +28,7 @@ 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');
diff --git a/app/Services/LinkedInService.php b/app/Services/LinkedInService.php
new file mode 100644
index 0000000..47d8eb3
--- /dev/null
+++ b/app/Services/LinkedInService.php
@@ -0,0 +1,264 @@
+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 = 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(),
+ ];
+ }
+ }
+}
diff --git a/config/linkedin.php b/config/linkedin.php
new file mode 100644
index 0000000..26c96b3
--- /dev/null
+++ b/config/linkedin.php
@@ -0,0 +1,11 @@
+ env('LINKEDIN_CLIENT_ID'),
+ 'client_secret' => env('LINKEDIN_CLIENT_SECRET'),
+ 'organization_id' => env('LINKEDIN_ORGANIZATION_ID', '35611757'),
+ 'redirect_uri' => env('LINKEDIN_REDIRECT_URI', 'https://truncgil.com/admin/linkedin/callback'),
+
+ // Scopes allowed by your LinkedIn App products
+ 'scopes' => explode(' ', env('LINKEDIN_SCOPES', 'w_member_social w_organization_social r_organization_admin rw_organization_admin')),
+];
diff --git a/fix_linkedin.php b/fix_linkedin.php
new file mode 100644
index 0000000..e955a5f
--- /dev/null
+++ b/fix_linkedin.php
@@ -0,0 +1,11 @@
+components/m', 'return $form->schema', $content);
+$content = str_replace('use Filament\Schemas\Components\Actions;', 'use Filament\Forms\Components\Actions;', $content);
+$content = str_replace('use Filament\Schemas\Components\Actions\Action;', 'use Filament\Forms\Components\Actions\Action;', $content);
+$content = str_replace('use Filament\Schemas\Components\Grid;', 'use Filament\Forms\Components\Grid;', $content);
+$content = str_replace('use Filament\Schemas\Components\Section;', 'use Filament\Forms\Components\Section;', $content);
+file_put_contents('/home/truncgil/web/truncgil.com/public_html/app/Filament/Admin/Pages/LinkedInSettings.php', $content);
+echo "Fixed!";
diff --git a/resources/views/filament/admin/pages/linked-in-settings.blade.php b/resources/views/filament/admin/pages/linked-in-settings.blade.php
new file mode 100644
index 0000000..5255b69
--- /dev/null
+++ b/resources/views/filament/admin/pages/linked-in-settings.blade.php
@@ -0,0 +1,5 @@
+