title . ' at ' . $job->company . ' - JobifyGarden'; $description = Str::limit(strip_tags($job->description), 155); $url = route('jobs.show', $job); 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(), 'identifier' => [ '@type' => 'PropertyValue', 'name' => 'JobifyGarden', 'value' => $job->external_id ?? (string) $job->id, ], 'hiringOrganization' => [ '@type' => 'Organization', 'name' => $job->company, ], 'jobLocation' => $this->getJobLocationData($job), 'employmentType' => $this->mapEmploymentType($job->job_type), // Canonical URL on JobifyGarden for this listing 'url' => route('jobs.show', $job), ]; // Remote jobs: add jobLocationType per Google's spec if ($job->remote_allowed) { $data['jobLocationType'] = 'TELECOMMUTE'; } 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['directApply'] = true; $data['applyUrl'] = $job->apply_url ?? $job->source_url; } return $data; } /** * Get job location data for structured data */ private function getJobLocationData(Job $job): array { // For fully remote jobs with no physical location, Google requires // jobLocationType: TELECOMMUTE (set in parent method) and still // recommends providing a jobLocation with a country if known. // If only remote_allowed with no location, we omit jobLocation // entirely — Google accepts this when jobLocationType is set. if ($job->remote_allowed && !$job->jobLocation && !$job->location) { return [ '@type' => 'Place', 'address' => [ '@type' => 'PostalAddress', 'addressCountry' => 'US', // Default fallback; most aggregated jobs are US/global ], ]; } $address = [ '@type' => 'PostalAddress', ]; if ($job->jobLocation) { if ($job->jobLocation->city) { $address['addressLocality'] = $job->jobLocation->city; } if ($job->jobLocation->state) { $address['addressRegion'] = $job->jobLocation->state; } if ($job->jobLocation->country) { $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, ]; } }