feat: integrate LinkedIn API for automated social media post publishing via service and console command

This commit is contained in:
Ümit Tunç
2026-08-07 23:43:38 +03:00
parent a51af8208d
commit 25cdfda897
11 changed files with 682 additions and 0 deletions
+6
View File
@@ -78,6 +78,12 @@ YOUTUBE_CHANNEL_ID=
YOUTUBE_TOPIC_CHANNEL_ID=UCEGzDgiExoGrwWEnpIdOGRA YOUTUBE_TOPIC_CHANNEL_ID=UCEGzDgiExoGrwWEnpIdOGRA
# Sunucu/cron senkronizasyonu için KISITLAMASIZ veya IP kısıtlı ayrı bir anahtar kullanın. # Sunucu/cron senkronizasyonu için KISITLAMASIZ veya IP kısıtlı ayrı bir anahtar kullanın.
YOUTUBE_API_KEY= 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) # İsteğe bağlı: playlist ID doğrudan (Topic uploads: UU + topic channel ID'den sonraki kısım)
YOUTUBE_PLAYLIST_ID= YOUTUBE_PLAYLIST_ID=
# İsteğe bağlı: yalnızca DistroKid yayınları için açıklama filtresi # İsteğe bağlı: yalnızca DistroKid yayınları için açıklama filtresi
@@ -0,0 +1,55 @@
<?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;
}
}
@@ -0,0 +1,214 @@
<?php
namespace App\Filament\Admin\Pages;
use App\Models\Setting;
use App\Services\LinkedInService;
use BackedEnum;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\Actions;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Section;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
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(Form $form): Form
{
$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 $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();
}
}
}
@@ -0,0 +1,54 @@
<?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']);
}
}
+53
View File
@@ -0,0 +1,53 @@
<?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());
}
}
}
}
+3
View File
@@ -2,7 +2,9 @@
namespace App\Providers; namespace App\Providers;
use App\Models\Blog;
use App\Models\Page; use App\Models\Page;
use App\Observers\BlogObserver;
use App\Observers\PageObserver; use App\Observers\PageObserver;
use Illuminate\Support\ServiceProvider; 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 model için observer kaydet // Page model için observer kaydet
Page::observe(PageObserver::class); Page::observe(PageObserver::class);
Blog::observe(BlogObserver::class);
if ($this->app->environment('production') || $this->app->environment('staging')) { if ($this->app->environment('production') || $this->app->environment('staging')) {
\Illuminate\Support\Facades\URL::forceScheme('https'); \Illuminate\Support\Facades\URL::forceScheme('https');
+264
View File
@@ -0,0 +1,264 @@
<?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 = 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(),
];
}
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
return [
'client_id' => 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')),
];
+11
View File
@@ -0,0 +1,11 @@
<?php
$content = file_get_contents('/home/truncgil/web/truncgil.com/public_html/app/Filament/Admin/Pages/LinkedInSettings.php');
$content = str_replace('use Filament\Schemas\Schema;', 'use Filament\Forms\Form;', $content);
$content = str_replace('public function form(Schema $schema): Schema', 'public function form(Form $form): Form', $content);
$content = preg_replace('/return \$schema\n\s*->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!";
@@ -0,0 +1,5 @@
<x-filament-panels::page>
<form wire:submit.prevent="saveSettings">
{{ $this->form }}
</form>
</x-filament-panels::page>
+6
View File
@@ -102,6 +102,12 @@ Route::prefix('admin/api')->middleware(['auth', \App\Http\Middleware\SuperAdminM
Route::post('/site-translations/batch-destroy', [SiteTranslationController::class, 'batchDestroy'])->name('api.site-translations.batch-destroy'); Route::post('/site-translations/batch-destroy', [SiteTranslationController::class, 'batchDestroy'])->name('api.site-translations.batch-destroy');
}); });
// LinkedIn OAuth Routes (Admin Yetkilendirme)
Route::middleware(['auth'])->prefix('admin/linkedin')->group(function () {
Route::get('/connect', [\App\Http\Controllers\Admin\LinkedInController::class, 'connect'])->name('admin.linkedin.connect');
Route::get('/callback', [\App\Http\Controllers\Admin\LinkedInController::class, 'callback'])->name('admin.linkedin.callback');
});
// Products & Services // Products & Services
Route::redirect('/urun-hizmet/Yazılım Danışmanlık', '/urun-hizmet/yazilim-danismanlik', 301); Route::redirect('/urun-hizmet/Yazılım Danışmanlık', '/urun-hizmet/yazilim-danismanlik', 301);
Route::get('/urun-hizmet/{slug}', [\App\Http\Controllers\ProductController::class, 'show'])->name('products.show'); Route::get('/urun-hizmet/{slug}', [\App\Http\Controllers\ProductController::class, 'show'])->name('products.show');