From 3a687b0a7f60d9fefce9213e13d9b3e171310a6a Mon Sep 17 00:00:00 2001 From: Doar Mihai Date: Sat, 4 Apr 2026 02:14:57 +0300 Subject: [PATCH] phase 9 --- .../Controllers/Web/SitemapController.php | 76 +++++ app/Services/SeoService.php | 285 ++++++++++++++++++ app/Services/SitemapService.php | 167 ++++++++++ public/robots.txt | 16 +- resources/views/home.blade.php | 32 +- resources/views/jobs/category.blade.php | 23 +- resources/views/jobs/index.blade.php | 24 +- resources/views/jobs/remote.blade.php | 25 +- resources/views/jobs/show.blade.php | 18 ++ resources/views/layouts/app.blade.php | 21 ++ routes/web.php | 7 + 11 files changed, 686 insertions(+), 8 deletions(-) create mode 100644 app/Http/Controllers/Web/SitemapController.php create mode 100644 app/Services/SeoService.php create mode 100644 app/Services/SitemapService.php diff --git a/app/Http/Controllers/Web/SitemapController.php b/app/Http/Controllers/Web/SitemapController.php new file mode 100644 index 0000000..55d0942 --- /dev/null +++ b/app/Http/Controllers/Web/SitemapController.php @@ -0,0 +1,76 @@ +cacheService->remember( + 'sitemap:main', + 3600, // 1 hour cache + fn() => $this->sitemapService->generateSitemap() + ); + + return response($xml, 200) + ->header('Content-Type', 'application/xml'); + } + + /** + * Generate and serve sitemap index + */ + public function sitemapIndex(): Response + { + $xml = $this->cacheService->remember( + 'sitemap:index', + 3600, + fn() => $this->sitemapService->generateSitemapIndex() + ); + + return response($xml, 200) + ->header('Content-Type', 'application/xml'); + } + + /** + * Generate and serve main sitemap (static pages) + */ + public function mainSitemap(): Response + { + $xml = $this->cacheService->remember( + 'sitemap:main-pages', + 3600, + fn() => $this->sitemapService->generateMainSitemap() + ); + + return response($xml, 200) + ->header('Content-Type', 'application/xml'); + } + + /** + * Generate and serve jobs sitemap + */ + public function jobsSitemap(): Response + { + $xml = $this->cacheService->remember( + 'sitemap:jobs', + 1800, // 30 minutes cache (jobs change more frequently) + fn() => $this->sitemapService->generateJobsSitemap() + ); + + return response($xml, 200) + ->header('Content-Type', 'application/xml'); + } +} diff --git a/app/Services/SeoService.php b/app/Services/SeoService.php new file mode 100644 index 0000000..7f815dd --- /dev/null +++ b/app/Services/SeoService.php @@ -0,0 +1,285 @@ +title . ' at ' . $job->company . ' - JobifyGarden'; + $description = Str::limit(strip_tags($job->description), 155); + $url = route('jobs.show', $job->id); + + return [ + 'title' => $title, + 'description' => $description, + 'keywords' => $this->generateJobKeywords($job), + 'canonical' => $url, + 'og_title' => $job->title . ' at ' . $job->company, + 'og_description' => $description, + 'og_url' => $url, + 'og_type' => 'website', + 'og_image' => asset('images/og-default.jpg'), + 'twitter_card' => 'summary_large_image', + 'twitter_title' => $job->title . ' at ' . $job->company, + 'twitter_description' => $description, + 'twitter_image' => asset('images/twitter-card-default.jpg'), + ]; + } + + /** + * Generate meta tags for category page + */ + public function getCategoryMetaTags(JobCategory $category, int $jobCount): array + { + $title = $category->name . ' Jobs - JobifyGarden'; + $description = "Browse {$jobCount} {$category->name} job opportunities. " . ($category->description ?? 'Find your next career in ' . $category->name . '.'); + $url = route('jobs.category', $category->slug); + + return [ + 'title' => $title, + 'description' => Str::limit($description, 155), + 'keywords' => $this->generateCategoryKeywords($category), + 'canonical' => $url, + 'og_title' => $category->name . ' Jobs', + 'og_description' => Str::limit($description, 155), + 'og_url' => $url, + 'og_type' => 'website', + 'og_image' => asset('images/og-default.jpg'), + 'twitter_card' => 'summary', + 'twitter_title' => $category->name . ' Jobs', + 'twitter_description' => Str::limit($description, 155), + ]; + } + + /** + * Generate meta tags for homepage + */ + public function getHomeMetaTags(int $jobCount): array + { + return [ + 'title' => 'JobifyGarden - Find Your Dream Job | ' . number_format($jobCount) . '+ Jobs', + 'description' => 'Discover thousands of job opportunities from multiple sources. Search remote jobs, full-time positions, and more. Your next career starts here.', + 'keywords' => 'jobs, careers, employment, job search, remote jobs, full-time jobs, part-time jobs, find jobs', + 'canonical' => route('home'), + 'og_title' => 'JobifyGarden - Find Your Dream Job', + 'og_description' => 'Discover thousands of job opportunities from multiple sources. Your next career starts here.', + 'og_url' => route('home'), + 'og_type' => 'website', + 'og_image' => asset('images/og-home.jpg'), + 'twitter_card' => 'summary_large_image', + 'twitter_title' => 'JobifyGarden - Find Your Dream Job', + 'twitter_description' => 'Discover thousands of job opportunities from multiple sources.', + 'twitter_image' => asset('images/twitter-card-home.jpg'), + ]; + } + + /** + * Generate keywords for a job + */ + private function generateJobKeywords(Job $job): string + { + $keywords = [ + $job->title, + $job->company, + $job->job_type, + $job->experience_level, + ]; + + if ($job->category) { + $keywords[] = $job->category->name; + } + + if ($job->location) { + $keywords[] = $job->location; + } + + if ($job->remote_allowed) { + $keywords[] = 'remote'; + $keywords[] = 'work from home'; + } + + return implode(', ', array_filter($keywords)); + } + + /** + * Generate keywords for a category + */ + private function generateCategoryKeywords(JobCategory $category): string + { + return implode(', ', [ + $category->name . ' jobs', + $category->name . ' careers', + $category->name . ' employment', + 'find ' . $category->name . ' jobs', + ]); + } + + /** + * Generate JSON-LD structured data for a job + */ + public function generateJobStructuredData(Job $job): array + { + $data = [ + '@context' => 'https://schema.org/', + '@type' => 'JobPosting', + 'title' => $job->title, + 'description' => strip_tags($job->description), + 'datePosted' => $job->posted_at?->toIso8601String() ?? $job->created_at->toIso8601String(), + 'hiringOrganization' => [ + '@type' => 'Organization', + 'name' => $job->company, + ], + 'jobLocation' => $this->getJobLocationData($job), + 'employmentType' => $this->mapEmploymentType($job->job_type), + ]; + + if ($job->expires_at) { + $data['validThrough'] = $job->expires_at->toIso8601String(); + } + + if ($job->salary_min || $job->salary_max) { + $data['baseSalary'] = $this->getSalaryData($job); + } + + if ($job->experience_level) { + $data['experienceRequirements'] = [ + '@type' => 'OccupationalExperienceRequirements', + 'monthsOfExperience' => $this->mapExperienceLevel($job->experience_level), + ]; + } + + if ($job->apply_url || $job->source_url) { + $data['url'] = $job->apply_url ?? $job->source_url; + } + + return $data; + } + + /** + * Get job location data for structured data + */ + private function getJobLocationData(Job $job): array + { + if ($job->remote_allowed) { + return [ + '@type' => 'Place', + 'address' => [ + '@type' => 'PostalAddress', + 'addressCountry' => 'Remote', + ], + ]; + } + + $address = [ + '@type' => 'PostalAddress', + ]; + + if ($job->jobLocation) { + $address['addressLocality'] = $job->jobLocation->city; + $address['addressRegion'] = $job->jobLocation->state; + $address['addressCountry'] = $job->jobLocation->country; + } elseif ($job->location) { + $address['name'] = $job->location; + } + + return [ + '@type' => 'Place', + 'address' => $address, + ]; + } + + /** + * Get salary data for structured data + */ + private function getSalaryData(Job $job): array + { + $currency = $job->salary_currency ?? 'USD'; + + if ($job->salary_min && $job->salary_max) { + return [ + '@type' => 'MonetaryAmount', + 'currency' => $currency, + 'value' => [ + '@type' => 'QuantitativeValue', + 'minValue' => $job->salary_min, + 'maxValue' => $job->salary_max, + 'unitText' => 'YEAR', + ], + ]; + } + + $value = $job->salary_min ?? $job->salary_max; + + return [ + '@type' => 'MonetaryAmount', + 'currency' => $currency, + 'value' => [ + '@type' => 'QuantitativeValue', + 'value' => $value, + 'unitText' => 'YEAR', + ], + ]; + } + + /** + * Map job type to schema.org employment type + */ + private function mapEmploymentType(?string $jobType): string + { + return match ($jobType) { + 'full-time', 'full_time' => 'FULL_TIME', + 'part-time', 'part_time' => 'PART_TIME', + 'contract' => 'CONTRACTOR', + 'temporary' => 'TEMPORARY', + 'internship' => 'INTERN', + 'freelance' => 'CONTRACTOR', + default => 'FULL_TIME', + }; + } + + /** + * Map experience level to months + */ + private function mapExperienceLevel(?string $level): int + { + return match ($level) { + 'entry' => 0, + 'mid' => 24, + 'senior' => 60, + 'lead' => 84, + 'executive' => 120, + default => 0, + }; + } + + /** + * Generate breadcrumb structured data + */ + public function generateBreadcrumbStructuredData(array $items): array + { + $listItems = []; + + foreach ($items as $index => $item) { + $listItems[] = [ + '@type' => 'ListItem', + 'position' => $index + 1, + 'name' => $item['name'], + 'item' => $item['url'], + ]; + } + + return [ + '@context' => 'https://schema.org', + '@type' => 'BreadcrumbList', + 'itemListElement' => $listItems, + ]; + } +} diff --git a/app/Services/SitemapService.php b/app/Services/SitemapService.php new file mode 100644 index 0000000..0372778 --- /dev/null +++ b/app/Services/SitemapService.php @@ -0,0 +1,167 @@ +'); + + // Add homepage + $this->addUrl($xml, route('home'), now(), 'daily', '1.0'); + + // Add jobs index + $this->addUrl($xml, route('jobs.index'), now(), 'daily', '0.9'); + + // Add remote jobs + $this->addUrl($xml, route('jobs.remote'), now(), 'daily', '0.9'); + + // Add categories + $categories = JobCategory::all(); + foreach ($categories as $category) { + $this->addUrl( + $xml, + route('jobs.category', $category->slug), + $category->updated_at, + 'daily', + '0.8' + ); + } + + // Add active jobs + $jobs = Job::where(function ($query) { + $query->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }) + ->orderBy('posted_at', 'desc') + ->limit(10000) // Limit to 10k most recent jobs + ->get(['id', 'posted_at', 'updated_at']); + + foreach ($jobs as $job) { + $this->addUrl( + $xml, + route('jobs.show', $job->id), + $job->updated_at ?? $job->posted_at, + 'weekly', + '0.7' + ); + } + + return $xml->asXML(); + } + + /** + * Add URL to sitemap + */ + private function addUrl( + \SimpleXMLElement $xml, + string $url, + ?\DateTimeInterface $lastmod = null, + string $changefreq = 'weekly', + string $priority = '0.5' + ): void { + $urlElement = $xml->addChild('url'); + $urlElement->addChild('loc', htmlspecialchars($url)); + + if ($lastmod) { + $urlElement->addChild('lastmod', $lastmod->format('Y-m-d')); + } + + $urlElement->addChild('changefreq', $changefreq); + $urlElement->addChild('priority', $priority); + } + + /** + * Generate sitemap index for large sites + */ + public function generateSitemapIndex(): string + { + $xml = new \SimpleXMLElement(''); + + // Add main sitemap + $this->addSitemapToIndex($xml, route('sitemap.main'), now()); + + // Add jobs sitemap + $this->addSitemapToIndex($xml, route('sitemap.jobs'), now()); + + return $xml->asXML(); + } + + /** + * Add sitemap to index + */ + private function addSitemapToIndex(\SimpleXMLElement $xml, string $url, \DateTimeInterface $lastmod): void + { + $sitemapElement = $xml->addChild('sitemap'); + $sitemapElement->addChild('loc', htmlspecialchars($url)); + $sitemapElement->addChild('lastmod', $lastmod->format('Y-m-d')); + } + + /** + * Generate main sitemap (static pages and categories) + */ + public function generateMainSitemap(): string + { + $xml = new \SimpleXMLElement(''); + + // Add homepage + $this->addUrl($xml, route('home'), now(), 'daily', '1.0'); + + // Add jobs index + $this->addUrl($xml, route('jobs.index'), now(), 'daily', '0.9'); + + // Add remote jobs + $this->addUrl($xml, route('jobs.remote'), now(), 'daily', '0.9'); + + // Add categories + $categories = JobCategory::all(); + foreach ($categories as $category) { + $this->addUrl( + $xml, + route('jobs.category', $category->slug), + $category->updated_at, + 'daily', + '0.8' + ); + } + + return $xml->asXML(); + } + + /** + * Generate jobs sitemap + */ + public function generateJobsSitemap(): string + { + $xml = new \SimpleXMLElement(''); + + // Add active jobs + $jobs = Job::where(function ($query) { + $query->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }) + ->orderBy('posted_at', 'desc') + ->limit(50000) // Sitemap limit + ->get(['id', 'posted_at', 'updated_at']); + + foreach ($jobs as $job) { + $this->addUrl( + $xml, + route('jobs.show', $job->id), + $job->updated_at ?? $job->posted_at, + 'weekly', + '0.7' + ); + } + + return $xml->asXML(); + } +} diff --git a/public/robots.txt b/public/robots.txt index eb05362..1667a30 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,2 +1,16 @@ +# Robots.txt for JobifyGarden User-agent: * -Disallow: +Allow: / +Disallow: /admin/ +Disallow: /api/ + +# Google-specific +User-agent: Googlebot +Allow: / + +# Sitemap location +Sitemap: https://jobifygarden.com/sitemap.xml +Sitemap: https://jobifygarden.com/sitemap-index.xml + +# Crawl delay (prevents aggressive crawling) +Crawl-delay: 1 diff --git a/resources/views/home.blade.php b/resources/views/home.blade.php index 65be9c5..8702e9b 100644 --- a/resources/views/home.blade.php +++ b/resources/views/home.blade.php @@ -1,7 +1,35 @@ @extends('layouts.app') -@section('title', 'JobifyGarden - Find Your Dream Job') -@section('description', 'Discover thousands of job opportunities from multiple sources. Browse remote jobs, full-time positions, and more.') +@section('title', 'JobifyGarden - Find Your Dream Job | ' . number_format($stats['active_jobs'] ?? 0) . '+ Jobs') +@section('description', 'Discover thousands of job opportunities from multiple sources. Browse remote jobs, full-time positions, and more. Your next career starts here.') +@section('canonical', route('home')) + +@push('structured_data') + + + +@endpush @section('main-class', '') diff --git a/resources/views/jobs/category.blade.php b/resources/views/jobs/category.blade.php index 9255993..12f2534 100644 --- a/resources/views/jobs/category.blade.php +++ b/resources/views/jobs/category.blade.php @@ -1,7 +1,28 @@ @extends('layouts.app') @section('title', $category->name . ' Jobs - JobifyGarden') -@section('description', 'Browse ' . $category->name . ' job opportunities. ' . ($category->description ?? '')) +@section('description', 'Browse ' . $jobs->total() . ' ' . $category->name . ' job opportunities. ' . ($category->description ?? 'Find your next career in ' . $category->name . '.')) +@section('canonical', route('jobs.category', $category->slug)) + +@push('structured_data') + + + +@endpush @section('content')
diff --git a/resources/views/jobs/index.blade.php b/resources/views/jobs/index.blade.php index da272a9..2e3d9a7 100644 --- a/resources/views/jobs/index.blade.php +++ b/resources/views/jobs/index.blade.php @@ -1,7 +1,27 @@ @extends('layouts.app') -@section('title', 'Browse Jobs - JobifyGarden') -@section('description', 'Browse thousands of job opportunities. Filter by location, job type, experience level, and more.') +@section('title', 'Browse Jobs - JobifyGarden | ' . number_format($jobs->total()) . ' Opportunities') +@section('description', 'Browse ' . number_format($jobs->total()) . ' job opportunities. Filter by location, job type, experience level, and more to find your perfect role.') +@section('canonical', route('jobs.index')) + +@push('structured_data') + + + +@endpush @section('content')
diff --git a/resources/views/jobs/remote.blade.php b/resources/views/jobs/remote.blade.php index be3f68a..8b35a31 100644 --- a/resources/views/jobs/remote.blade.php +++ b/resources/views/jobs/remote.blade.php @@ -1,7 +1,28 @@ @extends('layouts.app') -@section('title', 'Remote Jobs - JobifyGarden') -@section('description', 'Discover remote job opportunities from companies worldwide. Work from anywhere.') +@section('title', 'Remote Jobs - JobifyGarden | ' . number_format($jobs->total()) . ' Work From Home Opportunities') +@section('description', 'Discover ' . number_format($jobs->total()) . ' remote job opportunities from companies worldwide. Work from anywhere, find flexible positions.') +@section('canonical', route('jobs.remote')) + +@push('structured_data') + + + +@endpush @section('content')
diff --git a/resources/views/jobs/show.blade.php b/resources/views/jobs/show.blade.php index feeaed0..82444a4 100644 --- a/resources/views/jobs/show.blade.php +++ b/resources/views/jobs/show.blade.php @@ -2,6 +2,24 @@ @section('title', $job->title . ' at ' . $job->company . ' - JobifyGarden') @section('description', Str::limit(strip_tags($job->description), 155)) +@section('canonical', route('jobs.show', $job->id)) + +@push('structured_data') + + + +@endpush @section('content')
diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 328fa27..15eaefc 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -7,6 +7,27 @@ @yield('title', 'JobifyGarden - Find Your Dream Job') + + + + + + + + + + + + + + + + + + + + + @stack('structured_data') diff --git a/routes/web.php b/routes/web.php index e686384..4bf1b18 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,6 +3,7 @@ use App\Http\Controllers\Admin\DashboardController; use App\Http\Controllers\Web\HomeController; use App\Http\Controllers\Web\JobController; +use App\Http\Controllers\Web\SitemapController; use Illuminate\Support\Facades\Route; // Homepage @@ -16,6 +17,12 @@ Route::prefix('jobs')->group(function () { Route::get('/{id}', [JobController::class, 'show'])->name('jobs.show'); }); +// Sitemap routes +Route::get('/sitemap.xml', [SitemapController::class, 'index'])->name('sitemap'); +Route::get('/sitemap-index.xml', [SitemapController::class, 'sitemapIndex'])->name('sitemap.index'); +Route::get('/sitemap-main.xml', [SitemapController::class, 'mainSitemap'])->name('sitemap.main'); +Route::get('/sitemap-jobs.xml', [SitemapController::class, 'jobsSitemap'])->name('sitemap.jobs'); + // Admin routes (in production, protect these with authentication middleware) Route::prefix('admin')->name('admin.')->group(function () { Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');