Tul xxx Tul
User / IP
:
216.73.216.217
Host / Server
:
45.84.207.204 / aircan.me
System
:
Linux lt-bnk-web1726.main-hosting.eu 5.14.0-611.36.1.el9_7.x86_64 #1 SMP PREEMPT_DYNAMIC Tue Mar 3 11:23:52 EST 2026 x86_64
Command
|
Upload
|
Create
Mass Deface
|
Jumping
|
Symlink
|
Reverse Shell
Ping
|
Port Scan
|
DNS Lookup
|
Whois
|
Header
|
cURL
:
/
home
/
u931257429
/
domains
/
aircan.me
/
public_html
/
trabajostoremaylor
/
Viewing: index.php
<?php use App\Data\CountryStates; use App\Data\PhonePrefixes; use App\Services\JobsFeed; use App\Services\SiteSettings; require_once __DIR__ . '/app/helpers/Session.php'; spl_autoload_register(static function (string $class): void { $prefix = 'App\\'; $baseDir = __DIR__ . '/app/'; if (!str_starts_with($class, $prefix)) { return; } $relativeClass = substr($class, strlen($prefix)); $relativePath = str_replace('\\', '/', $relativeClass) . '.php'; $pathsToTry = [$baseDir . $relativePath]; $segments = explode('/', $relativePath); if (!empty($segments)) { $segments[0] = strtolower($segments[0]); $pathsToTry[] = $baseDir . implode('/', $segments); } foreach ($pathsToTry as $path) { if (file_exists($path)) { require_once $path; return; } } }); App\Helpers\Session::start(); $scriptDir = str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '')); $scriptDir = $scriptDir === '.' ? '' : rtrim($scriptDir, '/'); $basePath = $scriptDir === '/' ? '' : $scriptDir; $normalizedBasePath = rtrim($basePath, '/'); $apiBasePath = $normalizedBasePath === '' ? '' : (preg_replace('#/public$#', '', $normalizedBasePath) ?: ''); $jobsApiUrl = ($apiBasePath ?: '') . '/api/jobs.php'; $applyApiUrl = ($apiBasePath ?: '') . '/api/apply.php'; $jobsEventsUrl = ($apiBasePath ?: '') . '/api/jobs-events.php'; $manifestUrl = ($apiBasePath ?: '') . '/api/manifest.php'; $serviceWorkerUrl = ($apiBasePath ?: '') . '/sw.js'; $pwaIconUrl = ($apiBasePath ?: '') . '/api/pwa-icon.php?size=192'; $oneSignalConfig = require __DIR__ . '/app/config/onesignal.php'; $oneSignalAppId = trim((string)($oneSignalConfig['app_id'] ?? '')); $oneSignalEnabled = $oneSignalAppId !== ''; $oneSignalAllowLocalhost = !empty($oneSignalConfig['allow_localhost']); $oneSignalWorkerPath = ($apiBasePath ?: '') . '/push/onesignal/OneSignalSDKWorker.js'; $oneSignalWorkerScope = ($apiBasePath ?: '') . '/push/onesignal/js/'; $siteSettings = SiteSettings::get(); $siteName = trim((string)($siteSettings['site_name'] ?? 'Trabajostore')) ?: 'Trabajostore'; $siteLogoSetting = $siteSettings['site_logo'] ?? 'public/assets/img/favicon.png'; $defaultBrandLogo = 'public/assets/img/favicon.png'; $resolveLogoPath = static function (?string $path) use ($basePath): ?string { if (!$path) { return null; } if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) { return $path; } $normalized = '/' . ltrim($path, '/'); if (str_starts_with($normalized, '/uploads/')) { $normalized = '/public' . $normalized; } if (!$basePath || $basePath === '/') { return $normalized; } return rtrim($basePath, '/') . $normalized; }; $siteLogoUrl = $resolveLogoPath($siteLogoSetting) ?? $resolveLogoPath($defaultBrandLogo) ?? $defaultBrandLogo; $siteLogoUrl = $siteLogoUrl ?: $defaultBrandLogo; $extractInitials = static function (?string $value): string { $trimmed = trim((string) $value); if ($trimmed === '') { return 'TS'; } $parts = preg_split('/\s+/', $trimmed) ?: []; $initials = ''; foreach ($parts as $part) { $initials .= strtoupper(substr($part, 0, 1)); if (strlen($initials) >= 2) { break; } } return $initials ?: 'TS'; }; $deriveModeLabel = static function (?array $job): string { if (!$job) { return 'Define la modalidad'; } $type = strtolower($job['type'] ?? ''); $location = strtolower($job['location'] ?? ''); if (str_contains($type, 'remoto') || str_contains($location, 'remoto')) { return 'Remoto'; } if (str_contains($type, 'contrato') || str_contains($type, 'obra') || str_contains($type, 'servicios')) { return 'Contrato'; } return 'Presencial'; }; $jobs = JobsFeed::all(); array_walk($jobs, static function (&$job) use ($resolveLogoPath): void { $job['employer_logo_url'] = $resolveLogoPath($job['employer_logo'] ?? null); }); $jobTitles = array_values(array_filter(array_unique(array_map(static function (array $job): string { return trim((string)($job['title'] ?? '')); } , $jobs)))); sort($jobTitles, SORT_NATURAL | SORT_FLAG_CASE); $jobCountries = array_values(array_filter(array_unique(array_map(static function (array $job): string { return trim((string)($job['country'] ?? '')); }, $jobs)))); sort($jobCountries, SORT_NATURAL | SORT_FLAG_CASE); $countryStates = CountryStates::all(); $countryStatesJson = json_encode($countryStates, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($countryStatesJson === 'null') { $countryStatesJson = '{}'; } $jobStateAvailability = []; foreach ($jobs as $jobEntry) { $country = trim((string) ($jobEntry['country'] ?? '')); $state = trim((string) ($jobEntry['state'] ?? '')); if ($country === '') { continue; } if (!isset($jobStateAvailability[$country])) { $jobStateAvailability[$country] = []; } if ($state !== '') { $jobStateAvailability[$country][$state] = true; } } foreach ($jobStateAvailability as $country => $states) { $jobStateAvailability[$country] = array_keys($states); } $jobStateAvailabilityJson = json_encode($jobStateAvailability, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($jobStateAvailabilityJson === 'null') { $jobStateAvailabilityJson = '{}'; } $jobsCount = count($jobs); $initialJob = $jobs[0] ?? null; $initialJobsJson = json_encode($jobs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($initialJobsJson === 'null') { $initialJobsJson = '[]'; } $escape = static fn(?string $value): string => htmlspecialchars($value ?? '', ENT_QUOTES, 'UTF-8'); $encodeJob = static fn(array $job): string => htmlspecialchars(json_encode($job, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); $defaultLogo = $siteLogoUrl ?: $defaultBrandLogo; $initialModeLabel = $deriveModeLabel($initialJob ?? null); $initialPostedLabel = $initialJob['posted_label'] ?? 'Esperando una vacante'; $initialCompanyInitials = $extractInitials($initialJob['company'] ?? $siteName); $initialLogoUrl = $initialJob['employer_logo_url'] ?? null; $initialCompanyName = $initialJob['company'] ?? 'Empieza explorando una vacante'; $initialLocation = $initialJob['location'] ?? ''; $initialState = $initialJob['state'] ?? ''; $initialCountry = $initialJob['country'] ?? ''; $initialRegionLabel = trim(implode(', ', array_filter([$initialState, $initialCountry], static fn(string $value): bool => $value !== ''))); $initialRegionLabel = $initialRegionLabel !== '' ? $initialRegionLabel : 'Ubicación no especificada'; $initialVerified = !empty($initialJob['company_verified']); $featuredEmployersMap = []; foreach ($jobs as $job) { $companyName = trim((string) ($job['company'] ?? '')); if ($companyName === '') { continue; } $mapKey = strtolower($companyName); if (!isset($featuredEmployersMap[$mapKey])) { $featuredEmployersMap[$mapKey] = [ 'name' => $companyName, 'logo' => $job['employer_logo_url'] ?? null, 'initials' => $extractInitials($companyName), ]; continue; } if (empty($featuredEmployersMap[$mapKey]['logo']) && !empty($job['employer_logo_url'])) { $featuredEmployersMap[$mapKey]['logo'] = $job['employer_logo_url']; } } $featuredEmployers = array_slice(array_values($featuredEmployersMap), 0, 8); $featuredEmployersCount = count($featuredEmployers); $phonePrefixes = PhonePrefixes::all(); $phonePrefixesJson = json_encode($phonePrefixes, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($phonePrefixesJson === 'null') { $phonePrefixesJson = '[]'; } $aircanSignatureText = (static function (): string { $default = 'Creado por Aircan'; $loginViewPath = __DIR__ . '/views/auth/login.php'; if (!is_readable($loginViewPath)) { return $default; } $contents = file_get_contents($loginViewPath); if ($contents === false) { return $default; } if (preg_match('/ts_signature\s*:\s*(.+?)\s*(?:\*\/|-->)/u', $contents, $matches)) { $extracted = trim($matches[1]); return $extracted !== '' ? $extracted : $default; } return $default; })(); $aircanSignatureHtml = (static function (string $text) use ($escape): string { $keyword = 'Aircan'; $keywordLength = strlen($keyword); $linkTemplate = static function (string $label) use ($escape): string { return '<a class="link-aircan" href="https://aircan.me/" target="_blank" rel="noopener">' . $escape($label) . '</a>'; }; $position = stripos($text, $keyword); if ($position === false) { return $escape($text) . ' ' . $linkTemplate($keyword); } $before = substr($text, 0, $position); $label = substr($text, $position, $keywordLength); $after = substr($text, $position + $keywordLength); return $escape($before) . $linkTemplate($label) . $escape($after); })($aircanSignatureText); ?> <!DOCTYPE html> <html lang="es"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title><?= $escape($siteName) ?> | Encuentra tu próximo trabajo</title> <link rel="icon" type="image/png" href="<?= $escape($siteLogoUrl) ?>"> <link rel="manifest" href="<?= $escape($manifestUrl) ?>"> <meta name="theme-color" content="#0a53be"> <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css"> <?php if ($oneSignalEnabled): ?> <script src="https://cdn.onesignal.com/sdks/web/v16/OneSignalSDK.page.js" defer></script> <?php endif; ?> <style> :root { --ts-primary: #0a53be; --ts-accent: #ff7b00; --ts-light: #f5f7fb; } body { font-family: 'Inter', sans-serif; background: var(--ts-light); color: #1f2a37; min-height: 100vh; padding-top: 0; transition: padding 0.3s ease; } body.navbar-active { padding-top: 78px; } .job-region-pill { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.15rem 0.8rem; border-radius: 999px; background: rgba(10, 83, 190, 0.12); color: #0a53be; font-size: 0.78rem; font-weight: 600; white-space: nowrap; } .job-region-pill i { font-size: 0.9rem; } #stateSelectColumn select:disabled { background-color: #f8fafc; opacity: 0.75; cursor: not-allowed; } #stateSelectColumn { flex: 0 0 210px; max-width: 210px; } @media (max-width: 767px) { #stateSelectColumn { flex: 1 1 100%; max-width: 100%; } } .navbar { position: fixed; top: 0; left: 0; width: 100%; z-index: 1050; background: rgba(255, 255, 255, 0.97); box-shadow: 0 8px 30px rgba(15, 23, 42, 0.08); border-bottom: 1px solid rgba(15, 23, 42, 0.05); backdrop-filter: blur(16px); transform: translateY(-140%); opacity: 0; pointer-events: none; transition: transform 0.5s ease, opacity 0.5s ease; } .navbar.navbar-visible { transform: translateY(0); opacity: 1; pointer-events: auto; } .brand-mark { width: 48px; height: 48px; border-radius: 14px; background: rgba(10, 83, 190, 0.1); display: flex; align-items: center; justify-content: center; padding: 8px; } .brand-mark img { width: 100%; height: 100%; object-fit: contain; } .hero-logo .brand-mark { width: 76px; height: 76px; border-radius: 20px; padding: 10px; } .navbar-brand span { color: var(--ts-primary); } .nav-pill { border-radius: 999px !important; padding-inline: 1.25rem !important; } .nav-glow { box-shadow: 0 8px 20px rgba(10, 83, 190, 0.25); } @media (max-width: 991px) { .navbar .dropdown-menu, .navbar .navbar-nav { background: #fff; border-radius: 16px; padding: 1rem; box-shadow: 0 18px 40px rgba(15, 23, 42, 0.1); } } .hero-section { background: linear-gradient(120deg, rgba(10, 83, 190, 0.88), rgba(23, 37, 84, 0.9)), url('public/assets/img/portada.png') center/cover; border-bottom-left-radius: 80px; border-bottom-right-radius: 80px; color: #fff; position: relative; overflow: hidden; padding: 4.5rem 0 5rem; } .hero-container { position: relative; padding-top: 5rem; } .hero-top { display: flex; justify-content: space-between; align-items: center; position: absolute; top: -1.5rem; left: 0; right: 0; padding-inline: clamp(1rem, 3vw, 1.5rem); } .hero-content { margin-top: 1rem; } .hero-top .btn { border-radius: 999px; box-shadow: 0 12px 30px rgba(15, 23, 42, 0.25); } .hero-logo { display: flex; align-items: center; gap: 1rem; } .hero-logo strong { font-size: 1.35rem; } @media (max-width: 576px) { .hero-section { padding-top: 0.75rem; padding-bottom: 3rem; } .hero-container { padding-top: 0.5rem; } .hero-top { position: relative; top: 0; padding-inline: 0; flex-direction: column; gap: 1rem; margin-bottom: 0.75rem; } .hero-content { margin-top: 0; } } .search-box { background: transparent; border-radius: 0; padding: 0; box-shadow: none; width: 100%; } .search-box input, .search-box select { border: none; box-shadow: none !important; background: transparent; } .search-box select.form-select { min-width: 170px; padding-left: 0; font-weight: 500; color: #0f172a; background-image: linear-gradient(45deg, transparent 50%, #0a53be 50%), linear-gradient(135deg, #0a53be 50%, transparent 50%); background-position: calc(100% - 16px) calc(50% - 2px), calc(100% - 11px) calc(50% - 2px); background-size: 6px 6px, 6px 6px; background-repeat: no-repeat; } .search-box select.form-select:focus { border: none; box-shadow: none; color: #0a53be; } .search-chip { border-radius: 999px; border: 1px solid rgba(79, 70, 229, 0.2); background: rgba(79, 70, 229, 0.08); color: #4338ca; padding: 0.35rem 0.9rem; font-size: 0.85rem; cursor: pointer; transition: background 0.2s; } .search-chip:hover { background: rgba(79, 70, 229, 0.18); } .recent-searches-empty { font-size: 0.75rem; color: #94a3b8; } .job-board-empty { border-radius: 16px; border: 1px dashed #cbd5f5; background: #f8fafc; } .job-board { margin-top: 2.5rem; } .job-list-scroll { max-height: 640px; overflow-y: auto; padding-right: 0.5rem; margin-right: -0.5rem; } #jobDetailColumn { transition: opacity 0.2s ease; } .job-list-scroll::-webkit-scrollbar { width: 6px; } .job-list-scroll::-webkit-scrollbar-track { background: rgba(148, 163, 184, 0.2); border-radius: 999px; } .job-list-scroll::-webkit-scrollbar-thumb { background: rgba(15, 23, 42, 0.25); border-radius: 999px; } .job-card { border: 1px solid #e3e8ef; border-radius: 20px; padding: 1rem; background: #fff; transition: border-color 0.3s, transform 0.2s; cursor: pointer; } .job-card.active, .job-card:hover { border-color: var(--ts-primary); box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08); transform: translateY(-2px); } .job-detail-card { border-radius: 24px; background: #fff; box-shadow: 0 20px 60px rgba(15, 23, 42, 0.08); border: 1px solid rgba(15, 23, 42, 0.05); } .job-badge { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.35rem 0.8rem; border-radius: 999px; font-size: 0.85rem; font-weight: 500; background: rgba(10, 83, 190, 0.08); color: #0a53be; } .job-badge-muted { background: rgba(15, 23, 42, 0.05); color: #475569; } .employer-logo { width: 64px; height: 64px; border-radius: 20px; background: #f1f5f9; display: flex; align-items: center; justify-content: center; overflow: hidden; border: 1px solid rgba(15, 23, 42, 0.08); } .employer-logo img { width: 100%; height: 100%; object-fit: contain; } .employer-logo span { font-weight: 700; color: #0a53be; } .tag { border-radius: 999px; padding: 0.25rem 0.75rem; background: #eef2ff; color: #4c1d95; font-size: 0.8rem; } .job-meta-block { border-radius: 16px; background: #f8fafc; padding: 1rem 1.25rem; display: flex; flex-direction: column; gap: 0.35rem; } .job-meta-item { display: flex; align-items: center; gap: 0.5rem; color: #475569; font-size: 0.9rem; } .job-meta-item i { color: #0a53be; } .apply-pill { min-width: 180px; border-radius: 999px; } .apply-pill .bi { font-size: 1.25rem; } @media (max-width: 991px) { #jobDetailColumn { display: none; } .job-card { border-radius: 16px; } .job-card.active, .job-card:hover { transform: none; } .apply-pill { width: 100%; } } .modal-apply .modal-dialog { width: 100%; max-width: 640px; margin: 0 auto; padding: 1rem; } .modal-apply .modal-content { border: none; border-radius: 28px; box-shadow: 0 40px 80px rgba(15, 23, 42, 0.25); padding: 0.75rem 1.25rem 1.5rem; position: relative; overflow-x: hidden; touch-action: pan-y; } .modal-apply .modal-header { border: none; padding-bottom: 0; } .modal-apply .modal-body { padding-top: 0; } .modal-apply .job-sheet-handle { width: 64px; height: 5px; border-radius: 999px; background: #cbd5f5; margin: 0 auto 0.75rem; } .apply-back-btn { display: none; font-weight: 600; } @media (max-width: 991px) { .apply-back-btn { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0; } } .apply-sheet-body { padding-top: 0; max-height: calc(100vh - 260px); overflow-y: auto; padding-right: 0.25rem; overflow-x: hidden; touch-action: pan-y; } @media (max-width: 575px) { .modal-apply .modal-dialog { margin: 0; min-height: 100vh; display: flex; align-items: flex-end; padding: 0.5rem; } .modal-apply .modal-content { width: 100%; border-radius: 28px 28px 0 0; max-height: calc(100vh - 1rem); padding: 1rem 1.25rem 1.5rem; } .apply-sheet-body { max-height: calc(100vh - 240px); } } .required-badge { color: #dc3545; font-weight: 600; margin-left: 0.15rem; } .cv-hint { font-size: 0.85rem; color: #64748b; } .phone-meta { display: flex; align-items: center; gap: 0.35rem; font-size: 0.9rem; color: #475569; } .phone-meta .phone-flag { font-size: 1.1rem; } .featured-companies .featured-company-card { border-radius: 20px; border: 1px solid rgba(15, 23, 42, 0.08); padding: 0.85rem; height: 96px; background: linear-gradient(135deg, rgba(10, 83, 190, 0.04), rgba(79, 70, 229, 0.04)); display: flex; align-items: center; justify-content: center; transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; } .featured-companies .featured-company-card img { max-height: 48px; width: auto; filter: grayscale(1); opacity: 0.75; transition: opacity 0.2s ease, filter 0.2s ease; } .featured-companies .featured-company-card span { font-weight: 700; color: #0a53be; font-size: 1.25rem; } .featured-companies .featured-company-card:hover { border-color: rgba(10, 83, 190, 0.4); box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08); transform: translateY(-3px); } .featured-companies .featured-company-card:hover img { filter: grayscale(0); opacity: 1; } footer { background: #0f172a; color: rgba(255, 255, 255, 0.8); } footer .brand-mark-inverse { background: rgba(255, 255, 255, 0.12); } footer .footer-note { color: rgba(255, 255, 255, 0.7); } .link-aircan { color: #ffb547; font-weight: 600; text-decoration: none; border-bottom: 1px solid rgba(255, 181, 71, 0.4); padding-bottom: 2px; transition: color 0.2s ease, border-color 0.2s ease; } .link-aircan:hover { color: #ffd78a; border-color: rgba(255, 215, 138, 0.8); } @media (max-width: 991px) { .job-board { margin-top: 1.5rem; } .search-box { border-radius: 30px; } .search-box { background: transparent; box-shadow: none; padding: 0; } .search-box input, .search-box select { background: rgba(255, 255, 255, 0.85); border-radius: 999px; border: 1px solid rgba(148, 163, 184, 0.4); padding: 0.55rem 1.1rem; text-align: center; } .search-box select { text-align-last: center; background-position: calc(100% - 0.95rem) 50%; } .search-box input::placeholder { text-align: center; } .job-list-scroll { max-height: none; overflow: visible; padding-right: 0; margin-right: 0; } #desktopSearchWrapper { margin-top: 1.5rem; padding: 0; background: transparent; } #desktopSearchWrapper .search-box { background: transparent; box-shadow: none; border-radius: 0; padding: 0; } } #desktopSearchWrapper { position: relative; z-index: 30; margin-top: 3rem; } @media (min-width: 992px) { #desktopSearchWrapper .search-box { background: #fff; border-radius: 999px; padding: 0.75rem 1.25rem; box-shadow: 0 30px 80px rgba(15, 23, 42, 0.15); } #desktopSearchWrapper { max-width: 1080px; } } .job-sheet-modal .modal-dialog { width: 100%; max-width: 460px; margin: 0 auto; padding: 1rem; } .job-sheet-modal .modal-content { border-radius: 28px; border: none; padding: 1.25rem 1.25rem 1.5rem; box-shadow: 0 40px 80px rgba(15, 23, 42, 0.3); position: relative; } .job-sheet-header { text-align: center; margin-bottom: 1rem; } .job-sheet-handle { width: 64px; height: 5px; border-radius: 999px; background: #cbd5f5; margin: 0 auto 0.75rem; } .job-sheet-close { position: absolute; top: 0.75rem; right: 0.75rem; } .job-sheet-highlight { display: flex; gap: 1rem; align-items: flex-start; margin-bottom: 1rem; } .job-sheet-title { font-size: 1.15rem; font-weight: 600; margin-bottom: 0.25rem; } .job-sheet-company { font-weight: 500; margin-bottom: 0; } .job-sheet-location { font-size: 0.9rem; color: #475569; } .job-sheet-logo { width: 58px; height: 58px; border-radius: 18px; background: #f8fafc; display: flex; align-items: center; justify-content: center; overflow: hidden; border: 1px solid rgba(15, 23, 42, 0.08); } .job-sheet-logo img { width: 100%; height: 100%; object-fit: contain; } .job-sheet-meta { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.9rem; margin: 1rem 0 1.25rem; } .job-sheet-meta-item { display: flex; align-items: center; gap: 0.65rem; padding: 0.75rem; border-radius: 16px; background: #f8fafc; font-weight: 500; color: #0f172a; } .job-sheet-meta-item small { display: block; font-weight: 400; color: #64748b; font-size: 0.75rem; } .job-sheet-body { max-height: calc(100vh - 250px); overflow-y: auto; padding: 0 0.25rem 1.5rem; } @media (max-width: 575px) { .job-sheet-modal .modal-dialog { margin: 0; min-height: 100vh; display: flex; align-items: flex-end; padding: 0.5rem; } .job-sheet-modal .modal-content { border-radius: 28px 28px 0 0; width: 100%; max-height: calc(100vh - 1rem); margin: 0 auto; } .job-sheet-body { max-height: calc(100vh - 260px); padding-bottom: 1.25rem; } .job-sheet-close { top: 0.65rem; right: 0.65rem; } } </style> </head> <body> <img src="<?= $escape($siteLogoUrl) ?>" alt="<?= $escape($siteName) ?>" style="display:none;"> <nav class="navbar navbar-expand-lg bg-white py-3"> <div class="container"> <a class="navbar-brand fw-bold d-flex align-items-center gap-3" href="#"> <span class="brand-mark"> <img src="<?= $escape($siteLogoUrl) ?>" alt="<?= $escape($siteName) ?>"> </span> <span><?= $escape($siteName) ?></span> </a> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button> <div class="collapse navbar-collapse" id="mainNav"> <ul class="navbar-nav ms-auto gap-lg-3 align-items-lg-center"> <li class="nav-item"><a class="nav-link fw-medium" href="#ofertas">Buscar ofertas</a></li> <li class="nav-item"><a class="nav-link fw-medium" href="#empresas">Empresas</a></li> <li class="nav-item"><a class="nav-link fw-medium" href="#tips">Recursos</a></li> </ul> <div class="d-flex flex-column flex-lg-row gap-2 ms-lg-4 mt-3 mt-lg-0"> <button type="button" class="btn btn-outline-secondary nav-pill" id="jobAlertsButton"> <i class="bi bi-bell"></i> <span class="ms-1 d-none d-lg-inline">Alertas</span> </button> <a class="btn btn-primary nav-pill nav-glow" href="public/login">Iniciar sesión</a> </div> </div> </div> </nav> <header class="hero-section py-5"> <div class="container hero-container"> <div class="hero-top"> <div class="hero-logo"> <span class="brand-mark"> <img src="<?= $escape($siteLogoUrl) ?>" alt="<?= $escape($siteName) ?>"> </span> <strong><?= $escape($siteName) ?></strong> </div> <div class="d-flex gap-2"> <a class="btn btn-outline-light" href="#ofertas">Explorar empleos</a> <a class="btn btn-warning text-dark" href="public/login">Iniciar sesión</a> </div> </div> <div class="row align-items-center hero-content"> <div class="col-lg-7 text-center text-lg-start"> <span class="badge bg-warning text-dark mb-3"><?= $jobsCount ?> vacantes activas</span> <h1 class="display-5 fw-bold mb-3">Tu próximo paso profesional comienza en <?= $escape($siteName) ?></h1> <p class="lead mb-4">Explora ofertas de trabajo para el talento latino. Aplica en minutos y sigue el estado de tus postulaciones en un solo lugar.</p> </div> <div class="col-lg-5 mt-4 mt-lg-0 text-lg-end text-center"> <div class="bg-white text-dark rounded-4 p-4 shadow-lg"> <p class="text-uppercase text-muted small mb-1">Últimas búsquedas</p> <div id="recentSearches" class="d-flex flex-wrap gap-2"></div> <p id="recentSearchesEmpty" class="recent-searches-empty mb-0">Cuando realices búsquedas las verás aquí.</p> <hr> <p class="mb-0">Empresas verificadas publican a diario perfiles de tecnología, marketing, finanzas y más.</p> </div> </div> </div> </div> </header> <section class="container" id="desktopSearchWrapper"> <form id="jobSearchForm" class="search-box d-flex flex-column flex-md-row gap-2 align-items-center"> <div class="d-flex align-items-center flex-grow-1"> <i class="bi bi-briefcase text-primary fs-4 me-2"></i> <input id="searchTitle" name="title" class="form-control" type="text" placeholder="Título del empleo" list="jobTitleOptions" autocomplete="off" > </div> <div class="vr d-none d-md-block"></div> <div class="d-flex align-items-center flex-grow-1"> <i class="bi bi-globe-americas text-primary fs-4 me-2"></i> <select id="searchCountry" name="country" class="form-select"> <option value="">Todos los países</option> <?php foreach ($jobCountries as $country): ?> <option value="<?= $escape($country) ?>"><?= $escape($country) ?></option> <?php endforeach; ?> </select> </div> <div class="vr d-none d-md-block"></div> <div class="d-flex align-items-center flex-grow-1" id="stateSelectColumn"> <i class="bi bi-pin-map text-primary fs-4 me-2"></i> <select id="searchState" name="state" class="form-select" disabled> <option value="">Todos los estados</option> </select> </div> <div class="d-flex flex-column flex-sm-row gap-2"> <button class="btn btn-primary px-4 rounded-pill" type="submit"> <i class="bi bi-search me-2"></i>Buscar </button> <button class="btn btn-outline-light px-4 rounded-pill text-primary" type="button" id="resetSearchBtn"> <i class="bi bi-arrow-counterclockwise me-1"></i>Restablecer </button> </div> </form> <datalist id="jobTitleOptions"> <?php foreach ($jobTitles as $title): ?> <option value="<?= $escape($title) ?>"></option> <?php endforeach; ?> </datalist> </section> <main class="container job-board" id="ofertas"> <div class="row g-4"> <div class="col-lg-4"> <div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2"> <h2 class="h5 mb-0" id="jobCountText"> <?= $jobsCount === 1 ? '1 vacante disponible' : $jobsCount . ' vacantes disponibles' ?> </h2> <span class="text-muted small" id="jobCountMeta"> <?= $jobsCount > 0 ? 'Actualizado hace instantes' : 'Sin vacantes publicadas todavía' ?> </span> </div> <div class="d-flex flex-column gap-3 job-list-scroll" id="jobCardsList"> <?php if ($jobsCount === 0): ?> <div class="alert alert-light mb-0 text-center text-muted job-board-empty"> Aún no hay vacantes publicadas. Vuelve pronto. </div> <?php else: ?> <?php foreach ($jobs as $index => $job): ?> <?php $isActive = $index === 0 ? 'active' : ''; ?> <article class="job-card <?= $isActive ?>" data-job='<?= $encodeJob($job) ?>'> <div class="d-flex justify-content-between"> <div> <?php if (!empty($job['status'])): ?> <p class="text-uppercase text-warning small mb-1">Empleo destacado</p> <?php endif; ?> <h3 class="h6 mb-1"><?= $escape($job['title']) ?></h3> <p class="mb-1 text-muted"><?= $escape($job['company']) ?></p> <span class="text-muted small d-inline-flex align-items-center gap-2 flex-wrap"> <span><i class="bi bi-geo-alt"></i> <?= $escape($job['location']) ?></span> <?php if (!empty($job['state'])): ?> <span class="job-region-pill"><i class="bi bi-compass"></i> <?= $escape($job['state']) ?></span> <?php endif; ?> </span> </div> <i class="bi bi-three-dots-vertical text-muted"></i> </div> <div class="d-flex align-items-center justify-content-between mt-3"> <span class="fw-semibold"><?= App\Helpers\Utils::formatCurrency($job['salary']) ?></span> <small class="text-muted"><?= $escape($job['posted_label']) ?></small> </div> </article> <?php endforeach; ?> <?php endif; ?> </div> <div id="jobsEmptyState" class="alert alert-light text-center job-board-empty d-none mt-3"> <p class="mb-1 fw-semibold">No encontramos vacantes con esa búsqueda.</p> <p class="mb-0 small text-muted">Prueba con otras palabras clave o limpia los filtros.</p> </div> </div> <div class="col-lg-8" id="jobDetailColumn"> <section class="job-detail-card p-4" id="jobDetail"> <div class="d-flex flex-wrap align-items-start gap-3"> <div class="employer-logo" id="jobLogoWrapper"> <img id="jobLogoImage" src="<?= $initialLogoUrl ? $escape($initialLogoUrl) : '' ?>" alt="Logo de <?= $escape($initialCompanyName) ?>" class="<?= $initialLogoUrl ? '' : 'd-none' ?>"> <span id="jobLogoFallback" class="<?= $initialLogoUrl ? 'd-none' : '' ?>"><?= $escape($initialCompanyInitials) ?></span> </div> <div class="flex-grow-1"> <div class="d-flex align-items-center gap-2 flex-wrap mb-2"> <p class="text-uppercase text-muted small mb-0">Puesto seleccionado</p> <span class="job-badge job-badge-muted" id="jobMode"><?= $escape($initialModeLabel) ?></span> </div> <h2 class="h4 mb-1" id="jobTitle"> <?= $escape($initialJob['title'] ?? 'Selecciona una vacante para ver los detalles') ?> </h2> <div class="d-flex align-items-center gap-2 flex-wrap mb-1"> <span class="text-primary fw-semibold" id="jobCompany"> <?= $escape($initialCompanyName) ?> </span> <span id="jobVerifiedBadge" class="badge bg-success-subtle text-success d-flex align-items-center gap-1 <?= $initialVerified ? '' : 'd-none' ?>"> <i class="bi bi-patch-check-fill"></i> Empresa verificada </span> </div> <p class="text-muted mb-0 d-flex align-items-center gap-2 flex-wrap" id="jobLocation"> <?php if ($initialJob): ?> <span><i class="bi bi-geo-alt"></i> <?= $escape($initialLocation) ?></span> <?php else: ?> <span>Selecciona una posición para ver la ubicación</span> <?php endif; ?> <span class="job-region-pill <?= $initialJob ? '' : 'd-none' ?>" id="jobRegionBadge"> <?= $initialJob ? $escape($initialRegionLabel) : '' ?> </span> </p> </div> <div class="ms-auto"></div> </div> <div class="row g-3 mt-3"> <div class="col-md-4"> <div class="job-meta-block h-100"> <span class="text-uppercase text-muted small">Salario</span> <div class="job-meta-item"> <i class="bi bi-cash-stack"></i> <div> <p class="mb-0 fw-semibold" id="jobSalary"><?= App\Helpers\Utils::formatCurrency($initialJob['salary'] ?? 'Salario por confirmar') ?></p> <small class="text-muted">Compensación estimada</small> </div> </div> </div> </div> <div class="col-md-4"> <div class="job-meta-block h-100"> <span class="text-uppercase text-muted small">Tipo de contratación</span> <div class="job-meta-item"> <i class="bi bi-briefcase"></i> <div> <p class="mb-0 fw-semibold" id="jobType"><?= $escape($initialJob['type'] ?? 'Tipo no definido') ?></p> <small class="text-muted">Modalidad laboral</small> </div> </div> </div> </div> <div class="col-md-4"> <div class="job-meta-block h-100"> <span class="text-uppercase text-muted small">Publicado</span> <div class="job-meta-item"> <i class="bi bi-clock-history"></i> <div> <p class="mb-0 fw-semibold" id="jobPosted"><?= $escape($initialPostedLabel) ?></p> <small class="text-muted">Actualización</small> </div> </div> </div> </div> </div> <hr class="my-4"> <p class="lead" id="jobDescription"> <?= $escape($initialJob['description'] ?? 'Selecciona una vacante para conocer más detalles del puesto.') ?> </p> <div class="row g-4"> <div class="col-12"> <h3 class="h6 text-uppercase text-muted">Requisitos</h3> <ul id="jobRequirements" class="mb-0"> <?php if (!empty($initialJob['requirements'])): ?> <?php foreach ($initialJob['requirements'] as $requirement): ?> <li><?= $escape($requirement) ?></li> <?php endforeach; ?> <?php else: ?> <li class="text-muted small">El empleador no agregó requisitos.</li> <?php endif; ?> </ul> </div> </div> <div class="d-grid d-sm-flex gap-3 mt-4"> <button class="btn btn-primary btn-lg apply-pill d-flex align-items-center justify-content-center gap-2" type="button" data-bs-toggle="modal" data-bs-target="#applyModal"> <i class="bi bi-send-fill"></i> Aplicar ahora </button> </div> </section> </div> </div> </main> <div class="modal fade job-sheet-modal" id="jobSheetModal" tabindex="-1" aria-hidden="true"> <div class="modal-dialog modal-dialog-centered"> <div class="modal-content position-relative"> <button type="button" class="btn-close job-sheet-close" data-bs-dismiss="modal" aria-label="Cerrar"></button> <div class="job-sheet-header"> <div class="job-sheet-handle"></div> <div class="job-sheet-highlight"> <div class="job-sheet-logo" id="sheetLogoWrapper"> <img id="sheetLogoImage" src="" alt="Logo de la empresa" class="d-none"> <span id="sheetLogoFallback">TS</span> </div> <div class="flex-grow-1 text-start"> <p class="text-muted text-uppercase small mb-1">Vacante seleccionada</p> <h2 class="job-sheet-title mb-0" id="sheetJobTitle">Selecciona una vacante</h2> <p class="job-sheet-company mb-1" id="sheetJobCompany">Explora las oportunidades disponibles</p> <p class="job-sheet-location mb-0" id="sheetJobLocation">Cuando elijas un empleo verás la ubicación</p> <span class="job-region-pill d-none mt-2" id="sheetRegionBadge"></span> <div class="d-flex align-items-center gap-2 flex-wrap mt-2"> <span class="badge bg-success-subtle text-success d-flex align-items-center gap-1 d-none" id="sheetVerificationBadge"> <i class="bi bi-patch-check-fill"></i> Empresa verificada </span> <small class="text-muted" id="sheetPublishedLabel">Actualizado recientemente</small> </div> </div> </div> </div> <div class="job-sheet-body"> <div class="job-sheet-meta"> <div class="job-sheet-meta-item"> <i class="bi bi-cash-stack text-primary"></i> <div> <span id="sheetSalaryLabel">Salario por confirmar</span> <small>Compensación estimada</small> </div> </div> <div class="job-sheet-meta-item"> <i class="bi bi-briefcase-fill text-primary"></i> <div> <span id="sheetContractLabel">Tipo no definido</span> <small>Tipo de contratación</small> </div> </div> <div class="job-sheet-meta-item"> <i class="bi bi-clock-fill text-primary"></i> <div> <span id="sheetScheduleLabel">Actualizado recientemente</span> <small>Última actualización</small> </div> </div> <div class="job-sheet-meta-item"> <i class="bi bi-geo-alt-fill text-primary"></i> <div> <span id="sheetModeLabel">Modalidad por confirmar</span> <small>Modalidad</small> </div> </div> </div> <div class="job-sheet-section"> <h6 class="text-uppercase text-muted small mb-2">Descripción</h6> <p class="mb-0" id="sheetJobDescription">Selecciona una vacante para conocer la descripción completa.</p> </div> <div class="job-sheet-section"> <h6 class="text-uppercase text-muted small mb-2">Requisitos</h6> <ul class="job-sheet-list" id="sheetJobRequirements"> <li class="text-muted small">El empleador no agregó requisitos.</li> </ul> </div> </div> <div class="job-sheet-actions"> <button class="btn btn-primary flex-grow-1" type="button" id="sheetApplyBtn"> Aplicar </button> </div> </div> </div> </div> <section class="container my-5" id="empresas"> <div class="card border-0 shadow-sm p-4"> <div class="d-flex flex-column flex-lg-row align-items-lg-center justify-content-between mb-3"> <div> <p class="text-uppercase text-muted small mb-1">Aliados</p> <h2 class="h4 mb-0">Empresas que confían en <?= $escape($siteName) ?></h2> <p class="text-muted small mb-0"> <?= $featuredEmployersCount === 1 ? '1 empresa mostrando sus vacantes activas' : $featuredEmployersCount . ' empresas mostrando sus vacantes activas' ?> </p> </div> </div> <div class="row row-cols-2 row-cols-md-4 row-cols-lg-6 g-3 featured-companies"> <?php if ($featuredEmployersCount > 0): ?> <?php foreach ($featuredEmployers as $employer): ?> <div class="col text-center"> <div class="featured-company-card"> <?php if (!empty($employer['logo'])): ?> <img src="<?= $escape($employer['logo']) ?>" alt="Logo de <?= $escape($employer['name']) ?>"> <?php else: ?> <span><?= $escape($employer['initials']) ?></span> <?php endif; ?> </div> <p class="small text-muted fw-semibold mb-0 mt-2"><?= $escape($employer['name']) ?></p> </div> <?php endforeach; ?> <?php else: ?> <div class="col"> <div class="alert alert-light text-center mb-0 text-muted"> Aún no hay empleadores destacados, vuelve pronto. </div> </div> <?php endif; ?> </div> </div> </section> <div class="modal fade modal-apply" id="applyModal" tabindex="-1" aria-hidden="true"> <div class="modal-dialog modal-dialog-centered modal-lg"> <div class="modal-content"> <div class="job-sheet-handle d-lg-none"></div> <div class="modal-header flex-wrap gap-2"> <button type="button" class="btn btn-link text-primary apply-back-btn d-lg-none" id="applyBackButton"> <i class="bi bi-arrow-left"></i> Regresar </button> <div> <p class="text-uppercase text-muted small mb-1">Postula a <span id="applyJobTitle">(Selecciona una vacante)</span></p> <h2 class="h4 mb-0">Completa tus datos</h2> </div> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cerrar"></button> </div> <div class="modal-body apply-sheet-body"> <form id="applyForm" class="row g-3" enctype="multipart/form-data" novalidate> <input type="hidden" name="job_id" id="applyJobId"> <div class="col-md-6"> <label class="form-label">Nombre completo<span class="required-badge">*</span></label> <input type="text" class="form-control" name="name" id="applyName" placeholder="Tu nombre y apellidos" required data-field-label="Nombre completo"> </div> <div class="col-md-6"> <label class="form-label">Correo electrónico<span class="required-badge">*</span></label> <input type="email" class="form-control" name="email" id="applyEmail" placeholder="correo@ejemplo.com" required data-field-label="Correo electrónico"> </div> <div class="col-md-6"> <label class="form-label">WhatsApp / Teléfono<span class="required-badge">*</span></label> <input type="text" class="form-control" name="phone" id="applyPhone" placeholder="Ej: +57 300 000 0000" required data-field-label="WhatsApp / Teléfono"> <div class="phone-meta mt-2" id="applyPhoneMeta"> <span class="phone-flag" id="applyPhoneFlag">🌐</span> <span id="applyPhoneCountry">Añade el prefijo internacional (ej: +57)</span> </div> </div> <div class="col-md-6"> <label class="form-label">Experiencia relevante<span class="required-badge">*</span></label> <input type="text" class="form-control" name="experience" id="applyExperience" placeholder="Cargo actual, años de experiencia" required data-field-label="Experiencia relevante"> </div> <div class="col-12"> <label class="form-label d-flex flex-wrap align-items-center gap-2">Hoja de vida (PDF o Word)<span class="required-badge">*</span></label> <input type="file" class="form-control" name="resume" id="applyResume" accept=".pdf,.doc,.docx" required data-field-label="Hoja de vida"> <p class="cv-hint mb-0 mt-1">Máximo 5MB. Se enviará directamente al empleador.</p> </div> <div class="col-12"> <div class="alert alert-success d-none" id="applySuccess"></div> <div class="alert alert-danger d-none" id="applyError"></div> </div> <div class="col-12 d-flex justify-content-end gap-2"> <button type="button" class="btn btn-outline-secondary" id="applyCancelButton">Cancelar</button> <button type="submit" class="btn btn-primary" id="applySubmitBtn"> <span class="default-label">Enviar postulación</span> <span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span> </button> </div> </form> </div> </div> </div> </div> <footer class="py-5 mt-5"> <div class="container"> <div class="row align-items-center gy-4"> <div class="col-lg-7"> <div class="d-flex align-items-center gap-3"> <span class="brand-mark brand-mark-inverse"> <img src="<?= $escape($siteLogoUrl) ?>" alt="<?= $escape($siteName) ?>"> </span> <div> <p class="fw-semibold mb-1 fs-5 text-white"><?= $escape($siteName) ?></p> <p class="mb-0 footer-note">Conectamos talento latinoamericano con compañías que transforman la región.</p> </div> </div> </div> <div class="col-lg-5 text-lg-end"> <button type="button" class="btn btn-outline-light btn-sm d-none mb-3" id="pwaInstallButton"> <i class="bi bi-download me-1"></i> Instalar </button> <p class="footer-note mb-1"><?= $aircanSignatureHtml ?></p> <small class="footer-note">© <?php echo date('Y'); ?> <?= $escape($siteName) ?>. Todos los derechos reservados.</small> </div> </div> </div> </footer> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> <script> const jobsApiUrl = '<?= $jobsApiUrl ?>'; const applyApiUrl = '<?= $applyApiUrl ?>'; const jobsEventsUrl = '<?= $jobsEventsUrl ?>'; const initialJobs = <?= $initialJobsJson ?>; const defaultLogo = '<?= $defaultLogo ?>'; const basePath = <?= json_encode($basePath ?: '') ?>; const serviceWorkerUrl = <?= json_encode($serviceWorkerUrl) ?>; const pwaIconUrl = <?= json_encode($pwaIconUrl) ?>; const oneSignalEnabled = <?= $oneSignalEnabled ? 'true' : 'false' ?>; const oneSignalAppId = <?= json_encode($oneSignalAppId) ?>; const oneSignalWorkerPath = <?= json_encode(ltrim($oneSignalWorkerPath, '/')) ?>; const oneSignalWorkerScope = <?= json_encode($oneSignalWorkerScope) ?>; const oneSignalAllowLocalhost = <?= $oneSignalAllowLocalhost ? 'true' : 'false' ?>; const pwaInstallBtn = document.getElementById('pwaInstallButton'); const jobAlertsButton = document.getElementById('jobAlertsButton'); let deferredInstallPrompt = null; function isStandaloneMode() { return window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true; } function hideInstallButton() { if (pwaInstallBtn) { pwaInstallBtn.classList.add('d-none'); } } function showInstallButton() { if (pwaInstallBtn && !isStandaloneMode()) { pwaInstallBtn.classList.remove('d-none'); } } if (isStandaloneMode()) { hideInstallButton(); } window.addEventListener('beforeinstallprompt', (event) => { event.preventDefault(); deferredInstallPrompt = event; showInstallButton(); }); window.addEventListener('appinstalled', () => { deferredInstallPrompt = null; hideInstallButton(); }); pwaInstallBtn?.addEventListener('click', async () => { if (!deferredInstallPrompt) { return; } deferredInstallPrompt.prompt(); try { const choice = await deferredInstallPrompt.userChoice; if (choice && choice.outcome === 'accepted') { hideInstallButton(); } else { hideInstallButton(); } } finally { deferredInstallPrompt = null; } }); if ('serviceWorker' in navigator && serviceWorkerUrl) { window.addEventListener('load', () => { navigator.serviceWorker.register(serviceWorkerUrl).catch(() => {}); }); } function initOneSignal() { if (!oneSignalEnabled || !oneSignalAppId) { return; } window.OneSignalDeferred = window.OneSignalDeferred || []; window.OneSignalDeferred.push(async function (OneSignal) { try { await OneSignal.init({ appId: oneSignalAppId, allowLocalhostAsSecureOrigin: !!oneSignalAllowLocalhost, serviceWorkerPath: oneSignalWorkerPath, serviceWorkerParam: { scope: oneSignalWorkerScope }, }); } catch (_) { } }); } initOneSignal(); const applyModalElement = document.getElementById('applyModal'); const applyForm = document.getElementById('applyForm'); const applyJobTitle = document.getElementById('applyJobTitle'); const applyJobIdInput = document.getElementById('applyJobId'); const applySuccess = document.getElementById('applySuccess'); const applyError = document.getElementById('applyError'); const applySubmitBtn = document.getElementById('applySubmitBtn'); const applySubmitSpinner = applySubmitBtn?.querySelector('.spinner-border'); const applyName = document.getElementById('applyName'); const applyEmail = document.getElementById('applyEmail'); const applyPhone = document.getElementById('applyPhone'); const applyPhoneMeta = document.getElementById('applyPhoneMeta'); const applyPhoneFlag = document.getElementById('applyPhoneFlag'); const applyPhoneCountry = document.getElementById('applyPhoneCountry'); const applyBackButton = document.getElementById('applyBackButton'); const applyCancelButton = document.getElementById('applyCancelButton'); const applyExperience = document.getElementById('applyExperience'); const applyResume = document.getElementById('applyResume'); const jobTitle = document.getElementById('jobTitle'); const jobCompany = document.getElementById('jobCompany'); const jobLocation = document.getElementById('jobLocation'); const jobSalary = document.getElementById('jobSalary'); const jobType = document.getElementById('jobType'); const jobMode = document.getElementById('jobMode'); const jobPosted = document.getElementById('jobPosted'); const jobDescription = document.getElementById('jobDescription'); const jobRequirements = document.getElementById('jobRequirements'); const jobCountText = document.getElementById('jobCountText'); const jobVerifiedBadge = document.getElementById('jobVerifiedBadge'); const jobLogoImage = document.getElementById('jobLogoImage'); const jobLogoFallback = document.getElementById('jobLogoFallback'); const jobSearchForm = document.getElementById('jobSearchForm'); const searchTitle = document.getElementById('searchTitle'); const searchCountry = document.getElementById('searchCountry'); const searchState = document.getElementById('searchState'); const resetSearchBtn = document.getElementById('resetSearchBtn'); const jobCardsList = document.getElementById('jobCardsList'); const jobsEmptyState = document.getElementById('jobsEmptyState'); const recentSearches = document.getElementById('recentSearches'); const recentSearchesEmpty = document.getElementById('recentSearchesEmpty'); const jobDetailCard = document.getElementById('jobDetail'); const jobSheetModalElement = document.getElementById('jobSheetModal'); const sheetJobTitle = document.getElementById('sheetJobTitle'); const sheetJobCompany = document.getElementById('sheetJobCompany'); const sheetJobLocation = document.getElementById('sheetJobLocation'); const sheetLogoWrapper = document.getElementById('sheetLogoWrapper'); const sheetLogoImage = document.getElementById('sheetLogoImage'); const sheetLogoFallback = document.getElementById('sheetLogoFallback'); const sheetVerificationBadge = document.getElementById('sheetVerificationBadge'); const sheetPublishedLabel = document.getElementById('sheetPublishedLabel'); const sheetContractLabel = document.getElementById('sheetContractLabel'); const sheetScheduleLabel = document.getElementById('sheetScheduleLabel'); const sheetModeLabel = document.getElementById('sheetModeLabel'); const sheetSalaryLabel = document.getElementById('sheetSalaryLabel'); const sheetJobDescription = document.getElementById('sheetJobDescription'); const sheetJobRequirements = document.getElementById('sheetJobRequirements'); const sheetApplyBtn = document.getElementById('sheetApplyBtn'); const countryStatesData = <?= $countryStatesJson ?>; const jobStateAvailabilityData = <?= $jobStateAvailabilityJson ?>; const normalizedCountryStates = buildNormalizedIndex(countryStatesData); const normalizedStateAvailability = buildNormalizedIndex(jobStateAvailabilityData); const RECENT_SEARCHES_KEY = 'ts_recent_searches'; const JOB_ALERTS_KEY = 'ts_job_alerts_enabled'; const JOB_EVENTS_LAST_ID_KEY = 'ts_jobs_last_id'; let currentJobs = Array.isArray(initialJobs) ? [...initialJobs] : []; let preferredJobId = null; let shouldReturnToSheet = false; let pendingApplyFromSheet = false; function normalizeKey(value = '') { return value .toString() .trim() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .toLowerCase(); } function buildNormalizedIndex(source = {}) { return Object.keys(source || {}).reduce((acc, key) => { acc[normalizeKey(key)] = source[key]; return acc; }, {}); } function getStatesForCountry(country) { if (!country) { return []; } const normalized = normalizeKey(country); const canonicalStates = countryStatesData?.[country] || normalizedCountryStates[normalized] || []; const availableStates = jobStateAvailabilityData?.[country] || normalizedStateAvailability[normalized] || []; if (availableStates.length) { if (!canonicalStates.length) { return availableStates; } const canonicalSet = new Set(canonicalStates); const filtered = availableStates.filter(state => canonicalSet.has(state)); return filtered.length ? filtered : availableStates; } return canonicalStates; } function renderStateOptions(country = '', selectedState = '') { if (!searchState) { return; } const trimmedCountry = (country || '').trim(); const states = getStatesForCountry(trimmedCountry); searchState.innerHTML = ''; const placeholder = document.createElement('option'); placeholder.value = ''; placeholder.textContent = !trimmedCountry ? 'Selecciona un país primero' : states.length ? 'Todos los estados' : 'Sin estados disponibles'; searchState.appendChild(placeholder); if (!trimmedCountry || !states.length) { searchState.disabled = true; searchState.value = ''; placeholder.selected = true; return; } states.forEach(state => { const option = document.createElement('option'); option.value = state; option.textContent = state; searchState.appendChild(option); }); searchState.disabled = false; searchState.value = states.includes(selectedState) ? selectedState : ''; } function normalizePath(path = '') { if (!path) { return ''; } if (/^https?:\/\//i.test(path)) { return path; } const normalized = path.startsWith('/') ? path : `/${path}`; if (!basePath || basePath === '/') { return normalized; } const prefixed = `${basePath.replace(/\/$/, '')}${normalized}`; if (normalized.startsWith(`${basePath}/`)) { return normalized; } return prefixed; } function resolveLogoUrl(job = {}) { if (job.employer_logo_url) { return job.employer_logo_url; } return normalizePath(job.employer_logo || ''); } function getCompanyInitials(name = '') { const trimmed = name.trim(); if (!trimmed) { return 'TS'; } const parts = trimmed.split(/\s+/); const initials = parts.map(part => part.charAt(0).toUpperCase()).join(''); return initials.slice(0, 2) || 'TS'; } function deriveModeLabel(job) { if (!job) { return 'Define la modalidad'; } const type = (job.type || '').toLowerCase(); const location = (job.location || '').toLowerCase(); if (type.includes('remoto') || location.includes('remoto')) { return 'Remoto'; } if (type.includes('contrato') || type.includes('obra') || type.includes('servicios')) { return 'Contrato'; } return 'Presencial'; } function enrichJob(job = {}) { const enriched = { ...job }; enriched.employer_logo_url = resolveLogoUrl(enriched); enriched.company_verified = Boolean(enriched.company_verified); return enriched; } function updateLogo(job) { if (!job || !jobLogoImage || !jobLogoFallback) { if (jobLogoImage) { jobLogoImage.src = ''; jobLogoImage.classList.add('d-none'); } if (jobLogoFallback) { jobLogoFallback.textContent = 'TS'; jobLogoFallback.classList.remove('d-none'); } return; } const logoUrl = job.employer_logo_url || ''; const companyName = job.company || 'Empresa'; if (logoUrl) { jobLogoImage.src = logoUrl; jobLogoImage.alt = `Logo de ${companyName}`; jobLogoImage.classList.remove('d-none'); jobLogoFallback.classList.add('d-none'); } else { jobLogoImage.src = ''; jobLogoImage.classList.add('d-none'); jobLogoFallback.textContent = getCompanyInitials(companyName); jobLogoFallback.classList.remove('d-none'); } } function getJobSheetModal() { if (!jobSheetModalElement || typeof bootstrap === 'undefined') { return null; } return bootstrap.Modal.getOrCreateInstance(jobSheetModalElement); } function getApplyModal() { if (!applyModalElement || typeof bootstrap === 'undefined') { return null; } return bootstrap.Modal.getOrCreateInstance(applyModalElement); } function renderList(element, items = [], emptyMessage = '') { if (!element) { return; } if (Array.isArray(items) && items.length) { element.innerHTML = items.map(item => `<li>${item}</li>`).join(''); return; } element.innerHTML = emptyMessage ? `<li class="text-muted small">${emptyMessage}</li>` : ''; } function renderSheetRequirements(items = [], emptyMessage = '') { if (!sheetJobRequirements) { return; } if (Array.isArray(items) && items.length) { sheetJobRequirements.innerHTML = items.map(item => `<li>${item}</li>`).join(''); return; } sheetJobRequirements.innerHTML = `<li class="text-muted small">${emptyMessage || 'El empleador no agregó requisitos.'}</li>`; } function updateSheetLogo(job) { if (!sheetLogoImage || !sheetLogoFallback) { return; } if (job && job.employer_logo_url) { sheetLogoImage.src = job.employer_logo_url; sheetLogoImage.alt = `Logo de ${job.company || 'Empresa'}`; sheetLogoImage.classList.remove('d-none'); sheetLogoFallback.classList.add('d-none'); } else { sheetLogoImage.src = ''; sheetLogoImage.classList.add('d-none'); sheetLogoFallback.textContent = getCompanyInitials(job?.company || 'TS'); sheetLogoFallback.classList.remove('d-none'); } } function updateJobSheet(job) { if (!job) { sheetJobTitle.textContent = 'Selecciona una vacante'; sheetJobCompany.textContent = 'Explora las oportunidades disponibles'; sheetJobLocation.textContent = 'Cuando elijas un empleo verás la ubicación'; sheetPublishedLabel.textContent = 'Actualizado recientemente'; sheetSalaryLabel.textContent = 'Salario por confirmar'; sheetContractLabel.textContent = 'Tipo no definido'; sheetScheduleLabel.textContent = 'Jornada por confirmar'; sheetModeLabel.textContent = 'Modalidad por confirmar'; sheetJobDescription.textContent = 'Selecciona una vacante para conocer la descripción completa.'; renderSheetRequirements([], 'El empleador no agregó requisitos.'); sheetVerificationBadge?.classList.add('d-none'); updateSheetLogo(null); return; } sheetJobTitle.textContent = job.title || 'Vacante sin nombre'; sheetJobCompany.textContent = job.company || 'Empresa confidencial'; sheetJobLocation.innerHTML = job.location ? `<i class="bi bi-geo-alt"></i> ${job.location}` : 'Ubicación no especificada'; sheetPublishedLabel.textContent = job.published_at_label || job.posted_label || 'Reciente'; sheetSalaryLabel.textContent = job.salary || 'Salario por confirmar'; sheetContractLabel.textContent = job.type || 'Tipo no definido'; sheetScheduleLabel.textContent = job.published_at_label || job.posted_label || 'Reciente'; sheetModeLabel.textContent = deriveModeLabel(job); sheetJobDescription.textContent = job.description || 'El empleador no agregó descripción.'; renderSheetRequirements(job.requirements || [], 'El empleador no agregó requisitos.'); if (sheetVerificationBadge) { sheetVerificationBadge.classList.toggle('d-none', !job.company_verified); } updateSheetLogo(job); } function openJobSheet(job) { updateJobSheet(job); if (window.innerWidth >= 992) { return; } const modal = getJobSheetModal(); modal?.show(); } sheetApplyBtn?.addEventListener('click', () => { if (window.innerWidth >= 992) { shouldReturnToSheet = false; pendingApplyFromSheet = false; getApplyModal()?.show(); return; } shouldReturnToSheet = true; pendingApplyFromSheet = true; getJobSheetModal()?.hide(); }); applyBackButton?.addEventListener('click', () => { if (!shouldReturnToSheet) { return; } getApplyModal()?.hide(); }); jobSheetModalElement?.addEventListener('hidden.bs.modal', () => { if (!pendingApplyFromSheet) { return; } pendingApplyFromSheet = false; getApplyModal()?.show(); }); applyModalElement?.addEventListener('hidden.bs.modal', () => { if (shouldReturnToSheet && window.innerWidth < 992) { getJobSheetModal()?.show(); } shouldReturnToSheet = false; }); document.querySelectorAll('[data-bs-target="#applyModal"]').forEach(trigger => { trigger.addEventListener('click', () => { shouldReturnToSheet = false; pendingApplyFromSheet = false; }); }); applyCancelButton?.addEventListener('click', () => { shouldReturnToSheet = false; pendingApplyFromSheet = false; getApplyModal()?.hide(); getJobSheetModal()?.hide(); }); function updateJobDetail(job) { if (!job) { jobTitle.textContent = 'Selecciona un puesto para ver los detalles'; jobCompany.textContent = 'Empieza explorando una vacante'; jobLocation.textContent = 'Cuando selecciones una vacante verás la ubicación'; jobSalary.textContent = 'Salario por confirmar'; jobType.textContent = 'Tipo no definido'; jobMode.textContent = 'Define la modalidad'; jobPosted.textContent = 'Actualizado recientemente'; jobDescription.textContent = 'Selecciona una vacante para conocer más detalles del puesto.'; jobVerifiedBadge?.classList.add('d-none'); updateLogo(null); renderList(jobRequirements, [], 'El empleador no agregó requisitos.'); return; } jobTitle.textContent = job.title || 'Vacante sin nombre'; jobCompany.textContent = job.company || 'Empresa confidencial'; jobLocation.innerHTML = job.location ? `<i class="bi bi-geo-alt"></i> ${job.location}` : 'Ubicación no especificada'; jobSalary.textContent = job.salary || 'Salario por confirmar'; jobType.textContent = job.type || 'Tipo no definido'; jobMode.textContent = deriveModeLabel(job); jobPosted.textContent = job.published_at_label || 'Fecha no disponible'; jobDescription.textContent = job.description || 'El empleador no agregó descripción.'; renderList(jobRequirements, job.requirements || [], 'El empleador no agregó requisitos.'); if (jobVerifiedBadge) { jobVerifiedBadge.classList.toggle('d-none', !job.company_verified); } updateLogo(job); syncJobListHeight(); updateJobSheet(job); } function updateJobCount(count) { jobCountText.textContent = count === 1 ? '1 vacante disponible' : `${count} vacantes disponibles`; } function createJobCard(job, index) { const article = document.createElement('article'); article.className = `job-card${index === 0 ? ' active' : ''}`; article.dataset.index = index; article.innerHTML = ` <div class="d-flex justify-content-between align-items-start gap-2"> <div> ${job.status ? '<p class="text-uppercase text-warning small mb-1">Empleo destacado</p>' : ''} <h3 class="h6 mb-1">${job.title}</h3> <p class="mb-1 text-muted d-flex align-items-center gap-2 flex-wrap"> <span>${job.company}</span> ${job.company_verified ? '<span class="badge bg-success-subtle text-success small">Verificada</span>' : ''} </p> <div class="text-muted small d-flex flex-wrap gap-3"> <span><i class="bi bi-geo-alt"></i> ${job.location}</span> <span><i class="bi bi-briefcase"></i> ${job.type}</span> </div> </div> <i class="bi bi-chevron-right text-muted"></i> </div> <div class="d-flex align-items-center justify-content-between mt-3"> <span class="fw-semibold">${job.salary}</span> <small class="text-muted">${job.published_at_label || job.posted_label || 'Fecha no disponible'}</small> </div>`; article.addEventListener('click', () => { setActiveJob(index); openJobSheet(currentJobs[index] ?? null); }); return article; } function renderJobCards(jobs) { jobCardsList.innerHTML = ''; if (!jobs.length) { jobsEmptyState.classList.remove('d-none'); updateJobDetail(null); syncJobListHeight(); return; } jobsEmptyState.classList.add('d-none'); jobs.forEach((job, index) => { jobCardsList.appendChild(createJobCard(job, index)); }); syncJobListHeight(); } function setActiveJob(index) { const cards = jobCardsList.querySelectorAll('.job-card'); cards.forEach(card => card.classList.remove('active')); const selectedCard = cards[index]; if (selectedCard) { selectedCard.classList.add('active'); } const job = currentJobs[index] ?? null; updateJobDetail(job); syncApplyModalJob(job); } function applyJobs(jobs) { currentJobs = Array.isArray(jobs) ? jobs.map(enrichJob) : []; updateJobCount(currentJobs.length); renderJobCards(currentJobs); if (currentJobs.length > 0) { let indexToSelect = 0; if (preferredJobId !== null && preferredJobId !== '') { const desiredId = String(preferredJobId); const desiredIndex = currentJobs.findIndex(job => String(job?.id ?? '') === desiredId); if (desiredIndex >= 0) { indexToSelect = desiredIndex; } } setActiveJob(indexToSelect); preferredJobId = null; } syncJobListHeight(); if (currentJobs.length > 0) { const selectedIndex = jobCardsList?.querySelector('.job-card.active') ? [...jobCardsList.querySelectorAll('.job-card')].findIndex(card => card.classList.contains('active')) : 0; syncApplyModalJob(currentJobs[selectedIndex] ?? currentJobs[0] ?? null); } else { syncApplyModalJob(null); } } function getStoredLastJobId() { const stored = parseInt(localStorage.getItem(JOB_EVENTS_LAST_ID_KEY) || '0', 10); if (Number.isFinite(stored) && stored > 0) { return stored; } const initialMaxId = currentJobs.reduce((max, job) => { const value = parseInt(job?.id ?? 0, 10); return Number.isFinite(value) && value > max ? value : max; }, 0); if (initialMaxId > 0) { localStorage.setItem(JOB_EVENTS_LAST_ID_KEY, String(initialMaxId)); return initialMaxId; } return 0; } function setStoredLastJobId(value) { if (!Number.isFinite(value) || value <= 0) { return; } localStorage.setItem(JOB_EVENTS_LAST_ID_KEY, String(value)); } function isAlertsEnabled() { return localStorage.getItem(JOB_ALERTS_KEY) === '1'; } function setAlertsEnabled(enabled) { localStorage.setItem(JOB_ALERTS_KEY, enabled ? '1' : '0'); updateAlertsButton(); } function updateAlertsButton() { if (!jobAlertsButton) { return; } const permission = typeof Notification !== 'undefined' ? Notification.permission : 'denied'; const enabled = isAlertsEnabled(); jobAlertsButton.classList.remove('btn-outline-secondary', 'btn-outline-success', 'btn-outline-danger'); if (permission === 'granted' && enabled) { jobAlertsButton.classList.add('btn-outline-success'); jobAlertsButton.title = 'Alertas activas'; } else if (permission === 'denied') { jobAlertsButton.classList.add('btn-outline-danger'); jobAlertsButton.title = 'Notificaciones bloqueadas en el navegador'; } else { jobAlertsButton.classList.add('btn-outline-secondary'); jobAlertsButton.title = 'Activar alertas de nuevos empleos'; } } function ensureToastContainer() { let container = document.getElementById('tsToastContainer'); if (container) { return container; } container = document.createElement('div'); container.id = 'tsToastContainer'; container.className = 'toast-container position-fixed top-0 end-0 p-3'; container.style.zIndex = '2000'; document.body.appendChild(container); return container; } function showToast(title, message) { if (typeof bootstrap === 'undefined' || !bootstrap.Toast) { return; } const container = ensureToastContainer(); const toastEl = document.createElement('div'); toastEl.className = 'toast align-items-center text-bg-dark border-0'; toastEl.setAttribute('role', 'alert'); toastEl.setAttribute('aria-live', 'assertive'); toastEl.setAttribute('aria-atomic', 'true'); toastEl.innerHTML = ` <div class="d-flex"> <div class="toast-body"> <div class="fw-semibold">${title}</div> <div class="small">${message}</div> </div> <button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Cerrar"></button> </div> `; container.appendChild(toastEl); const toast = new bootstrap.Toast(toastEl, { delay: 6500 }); toastEl.addEventListener('hidden.bs.toast', () => { toastEl.remove(); }); toast.show(); } async function showSystemNotification(payload) { if (typeof Notification === 'undefined') { return; } if (Notification.permission !== 'granted') { return; } const title = payload?.title ? `Nuevo empleo: ${payload.title}` : 'Nuevo empleo publicado'; const locationParts = [payload?.state, payload?.country].filter(Boolean); const locationLabel = locationParts.length ? locationParts.join(', ') : (payload?.location || ''); const bodyParts = [payload?.company, locationLabel].filter(Boolean); const body = bodyParts.join(' · '); const urlBuilder = new URL(window.location.href); urlBuilder.searchParams.set('job_id', String(payload.id)); urlBuilder.hash = '#ofertas'; const url = urlBuilder.toString(); try { if ('serviceWorker' in navigator) { const registration = await navigator.serviceWorker.ready; await registration.showNotification(title, { body, icon: pwaIconUrl || undefined, badge: pwaIconUrl || undefined, data: { url }, }); return; } } catch (_) { } try { const notification = new Notification(title, { body, icon: pwaIconUrl || undefined }); notification.onclick = () => { window.focus(); window.location.href = url; }; } catch (_) { } } function refreshJobsSilently() { const title = searchTitle?.value?.trim?.() || ''; const country = searchCountry?.value?.trim?.() || ''; const state = !searchState || searchState.disabled ? '' : (searchState.value.trim() || ''); const activeId = applyJobIdInput?.value ? String(applyJobIdInput.value) : ''; preferredJobId = activeId; fetchJobs(title, country, state) .then(applyJobs) .catch(() => {}); } function handleJobPublished(payload) { const jobTitle = payload?.title || 'Nueva vacante'; const company = payload?.company || 'Empresa'; showToast('Nueva vacante publicada', `${jobTitle} · ${company}`); refreshJobsSilently(); if (isAlertsEnabled()) { showSystemNotification(payload); } } function startJobsEvents() { if (!jobsEventsUrl || typeof EventSource === 'undefined') { return; } const lastId = getStoredLastJobId(); const url = lastId > 0 ? `${jobsEventsUrl}?last_id=${encodeURIComponent(String(lastId))}` : jobsEventsUrl; const source = new EventSource(url); source.addEventListener('job_published', event => { try { const payload = JSON.parse(event.data || '{}'); const id = parseInt(payload?.id ?? 0, 10); if (Number.isFinite(id) && id > 0) { setStoredLastJobId(id); } handleJobPublished(payload); } catch (_) { } }); } function initAlerts() { const permission = typeof Notification !== 'undefined' ? Notification.permission : 'denied'; if (permission === 'denied') { setAlertsEnabled(false); } updateAlertsButton(); jobAlertsButton?.addEventListener('click', async () => { if (oneSignalEnabled && typeof window.OneSignalDeferred !== 'undefined') { window.OneSignalDeferred.push(async function (OneSignal) { try { if (OneSignal.User?.PushSubscription?.optedIn) { await OneSignal.User.PushSubscription.optOut(); setAlertsEnabled(false); return; } await OneSignal.User?.PushSubscription?.optIn?.(); setAlertsEnabled(true); } catch (_) { if (typeof Notification !== 'undefined') { try { await Notification.requestPermission(); } catch (_) { } } } }); return; } if (typeof Notification === 'undefined') { return; } if (Notification.permission === 'denied') { alert('Las notificaciones están bloqueadas en tu navegador. Habilítalas en la configuración del sitio.'); return; } if (Notification.permission !== 'granted') { const result = await Notification.requestPermission(); if (result !== 'granted') { setAlertsEnabled(false); return; } } setAlertsEnabled(!isAlertsEnabled()); }); startJobsEvents(); } function syncJobListHeight() { if (!jobCardsList || !jobDetailCard) { return; } if (window.innerWidth < 992) { jobCardsList.style.maxHeight = 'none'; return; } const detailHeight = jobDetailCard.offsetHeight; if (detailHeight > 0) { jobCardsList.style.maxHeight = `${detailHeight}px`; } } const PHONE_PREFIXES = <?= $phonePrefixesJson ?>; const PHONE_PREFIX_MAP = new Map(); PHONE_PREFIXES.forEach(entry => { entry.codes.forEach(code => { PHONE_PREFIX_MAP.set(code, { country: entry.country, flag: entry.flag }); }); }); const SORTED_PHONE_CODES = Array.from(PHONE_PREFIX_MAP.keys()).sort((a, b) => b.length - a.length); function detectPhoneCountry(value = '') { if (!value) { return null; } const sanitized = value.trim().replace(/[^+0-9]/g, ''); if (!sanitized.startsWith('+')) { return null; } for (const code of SORTED_PHONE_CODES) { if (sanitized.startsWith(code)) { const info = PHONE_PREFIX_MAP.get(code); return { code, ...info }; } } return null; } function syncApplyModalJob(job) { if (!applyJobTitle || !applyJobIdInput) { return; } if (!job) { applyJobTitle.textContent = '(Selecciona una vacante)'; applyJobIdInput.value = ''; return; } applyJobTitle.textContent = job.title || '(Vacante sin nombre)'; applyJobIdInput.value = job.id || ''; } function toggleApplyFeedback(type, message) { if (!applySuccess || !applyError) { return; } applySuccess.classList.add('d-none'); applyError.classList.add('d-none'); if (type === 'success' && message) { applySuccess.textContent = message; applySuccess.classList.remove('d-none'); } if (type === 'error' && message) { applyError.textContent = message; applyError.classList.remove('d-none'); } } const APPLY_REQUIRED_FIELDS = [ { element: applyName, label: 'Nombre completo' }, { element: applyEmail, label: 'Correo electrónico' }, { element: applyPhone, label: 'WhatsApp / Teléfono' }, { element: applyExperience, label: 'Experiencia relevante' }, { element: applyResume, label: 'Hoja de vida', isFile: true } ]; function markFieldValidity(field, isValid) { if (!field) { return; } field.classList.toggle('is-invalid', !isValid); } function getFieldFilled(field, isFile = false) { if (!field) { return false; } if (isFile) { return field.files && field.files.length > 0; } return (field.value || '').trim() !== ''; } function resetApplyValidationState() { APPLY_REQUIRED_FIELDS.forEach(({ element }) => markFieldValidity(element, true)); } function validateApplyFields(showMessage = false) { if (!applyForm) { return true; } const missing = []; APPLY_REQUIRED_FIELDS.forEach(({ element, label, isFile }) => { const filled = getFieldFilled(element, isFile); markFieldValidity(element, filled); if (!filled) { missing.push(label); } }); if (missing.length && showMessage) { const message = `Completa los siguientes campos: ${missing.join(', ')}.`; toggleApplyFeedback('error', message); const firstMissing = APPLY_REQUIRED_FIELDS.find(({ element, label }) => missing.includes(label)); firstMissing?.element?.focus?.(); } return missing.length === 0; } [applyName, applyEmail, applyPhone, applyExperience].forEach(field => { field?.addEventListener('input', () => markFieldValidity(field, true)); }); applyResume?.addEventListener('change', () => markFieldValidity(applyResume, true)); function setApplySubmitting(isSubmitting) { if (!applySubmitBtn || !applySubmitSpinner) { return; } applySubmitBtn.disabled = isSubmitting; applySubmitSpinner.classList.toggle('d-none', !isSubmitting); } function updatePhoneMeta() { if (!applyPhone || !applyPhoneMeta || !applyPhoneFlag || !applyPhoneCountry) { return; } const detected = detectPhoneCountry(applyPhone.value); if (detected) { applyPhoneFlag.textContent = detected.flag || '📍'; applyPhoneCountry.textContent = `${detected.country} (${detected.code})`; applyPhoneMeta.classList.remove('text-muted'); } else { applyPhoneFlag.textContent = '🌐'; applyPhoneCountry.textContent = 'Añade el prefijo internacional (ej: +57)'; applyPhoneMeta.classList.add('text-muted'); } } function validatePhonePrefix(showMessage = false) { if (!applyPhone) { return true; } const detected = detectPhoneCountry(applyPhone.value); if (!detected) { const message = 'Incluye el prefijo internacional con "+" (ej: +57)'; applyPhone.setCustomValidity(message); if (showMessage) { applyPhone.reportValidity(); } return false; } applyPhone.setCustomValidity(''); return true; } function getApplyApiCandidates() { const candidates = []; if (applyApiUrl) { candidates.push(applyApiUrl); } const normalizedBase = (basePath || '').replace(/\/$/, ''); if (normalizedBase) { candidates.push(`${normalizedBase}/api/apply.php`); } if (basePath?.includes('/public')) { candidates.push('/trabajostore5/api/apply.php'); } return Array.from(new Set(candidates.filter(Boolean))); } function createFormDataFactory(formData) { const snapshot = Array.from(formData.entries()); return () => { const clone = new FormData(); snapshot.forEach(([key, value]) => { if (value instanceof File) { clone.append(key, value, value.name); } else { clone.append(key, value); } }); return clone; }; } async function sendApplicationRequest(formData) { const candidates = getApplyApiCandidates(); const formFactory = createFormDataFactory(formData); let lastError = null; for (const url of candidates) { try { const response = await fetch(url, { method: 'POST', body: formFactory(), }); const rawText = await response.text(); let payload; try { payload = rawText ? JSON.parse(rawText) : null; } catch (parseError) { lastError = parseError; console.warn(`Respuesta no válida desde ${url}`, rawText); continue; } return { response, payload }; } catch (error) { lastError = error; console.warn(`No fue posible contactar ${url}`, error); } } throw lastError ?? new Error('No fue posible contactar al servidor.'); } async function submitApplication(event) { event.preventDefault(); if (!applyForm) { return; } toggleApplyFeedback(); if (!validateApplyFields(true)) { return; } if (!applyJobIdInput?.value) { toggleApplyFeedback('error', 'Selecciona una vacante antes de postularte.'); return; } if (!validatePhonePrefix(true)) { return; } const formData = new FormData(applyForm); formData.set('job_id', applyJobIdInput.value); try { setApplySubmitting(true); const { response, payload } = await sendApplicationRequest(formData); if (!response.ok || !payload.success) { const errorMessage = payload?.message || 'No pudimos registrar tu postulación.'; toggleApplyFeedback('error', errorMessage); return; } toggleApplyFeedback('success', payload.message || '¡Postulación enviada!'); applyForm.reset(); resetApplyValidationState(); updatePhoneMeta(); console.log('submit success'); } catch (error) { console.error('Error enviando postulación', error); toggleApplyFeedback('error', 'Se produjo un error inesperado. Inténtalo nuevamente.'); } finally { setApplySubmitting(false); } } applyForm?.addEventListener('submit', submitApplication); applyPhone?.addEventListener('input', () => { updatePhoneMeta(); validatePhonePrefix(false); }); applyPhone?.addEventListener('blur', () => validatePhonePrefix(true)); function loadRecentSearches() { try { return JSON.parse(localStorage.getItem(RECENT_SEARCHES_KEY)) || []; } catch (error) { return []; } } function persistRecentSearches(items) { localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(items.slice(0, 6))); } function normalizeSearchItem(item = {}) { return { title: (item.title ?? item.keyword ?? '').trim(), country: (item.country ?? item.location ?? '').trim(), state: (item.state ?? '').trim() }; } function saveRecentSearch(title, country, state) { const trimmedTitle = title.trim(); const trimmedCountry = country.trim(); const trimmedState = state.trim(); if (!trimmedTitle && !trimmedCountry && !trimmedState) { return; } const items = loadRecentSearches(); const filtered = items .map(normalizeSearchItem) .filter(item => item.title !== trimmedTitle || item.country !== trimmedCountry || item.state !== trimmedState); filtered.unshift({ title: trimmedTitle, country: trimmedCountry, state: trimmedState }); persistRecentSearches(filtered); renderRecentSearches(); } function renderRecentSearches() { const searches = loadRecentSearches().map(normalizeSearchItem); recentSearches.innerHTML = ''; if (!searches.length) { recentSearchesEmpty.classList.remove('d-none'); return; } recentSearchesEmpty.classList.add('d-none'); searches.slice(0, 4).forEach(({ title, country, state }) => { const labelTitle = title || 'Todos los cargos'; let locationLabel = ''; if (country && state) { locationLabel = `${state}, ${country}`; } else if (country) { locationLabel = country; } const labelCountry = locationLabel ? ` · ${locationLabel}` : ''; const chip = document.createElement('button'); chip.type = 'button'; chip.className = 'search-chip border-0'; chip.textContent = `${labelTitle}${labelCountry}`; chip.addEventListener('click', () => { if (searchTitle) searchTitle.value = title; if (searchCountry) searchCountry.value = country; const desiredState = country ? state : ''; renderStateOptions(country, desiredState); loadJobs(title, country, desiredState && !searchState?.disabled ? desiredState : ''); }); recentSearches.appendChild(chip); }); } function fetchJobs(title, country, state) { const params = new URLSearchParams(); if (title) params.set('title', title); if (country) params.set('country', country); if (state) params.set('state', state); const url = params.toString() ? `${jobsApiUrl}?${params.toString()}` : jobsApiUrl; return fetch(url, { headers: { 'Accept': 'application/json' } }).then(response => { if (!response.ok) { throw new Error('No fue posible obtener las vacantes'); } return response.json(); }); } function loadJobs(title = '', country = '', state = '') { const submitButton = jobSearchForm?.querySelector('button[type="submit"]'); if (submitButton) { submitButton.disabled = true; submitButton.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Buscando...'; } fetchJobs(title, country, state) .then(applyJobs) .catch(() => { alert('No fue posible cargar las vacantes en este momento.'); }) .finally(() => { if (submitButton) { submitButton.disabled = false; submitButton.innerHTML = '<i class="bi bi-search me-2"></i>Buscar empleos'; } }); } jobSearchForm?.addEventListener('submit', event => { event.preventDefault(); const title = searchTitle?.value.trim() || ''; const country = searchCountry?.value.trim() || ''; const state = !searchState || searchState.disabled ? '' : (searchState.value.trim() || ''); loadJobs(title, country, state); saveRecentSearch(title, country, state); }); searchCountry?.addEventListener('change', () => { const selectedCountry = searchCountry.value.trim(); renderStateOptions(selectedCountry, ''); }); (function () { const urlParams = new URLSearchParams(window.location.search || ''); const requestedJobId = urlParams.get('job_id'); if (requestedJobId) { preferredJobId = requestedJobId; } })(); applyJobs(currentJobs); renderRecentSearches(); syncJobListHeight(); renderStateOptions(searchCountry?.value || '', searchState?.value || ''); initAlerts(); window.addEventListener('resize', () => { syncJobListHeight(); }); resetSearchBtn?.addEventListener('click', () => { if (searchTitle) searchTitle.value = ''; if (searchCountry) searchCountry.value = ''; renderStateOptions('', ''); loadJobs(); }); window.addEventListener('resize', syncJobListHeight); window.addEventListener('orientationchange', syncJobListHeight); </script> </body> </html>
Coded With 💗 by
0x6ick