diff --git a/.prettierrc b/.prettierrc index 92805fa..30e2a53 100644 --- a/.prettierrc +++ b/.prettierrc @@ -4,14 +4,8 @@ "singleAttributePerLine": false, "htmlWhitespaceSensitivity": "css", "printWidth": 80, - "plugins": [ - "prettier-plugin-tailwindcss" - ], - "tailwindFunctions": [ - "clsx", - "cn", - "cva" - ], + "plugins": ["prettier-plugin-tailwindcss"], + "tailwindFunctions": ["clsx", "cn", "cva"], "tailwindStylesheet": "resources/css/app.css", "tabWidth": 4, "overrides": [ diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index f4cc770..6ce5e93 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -38,10 +38,53 @@ public function share(Request $request): array return [ ...parent::share($request), 'name' => config('app.name'), + 'seo' => $this->seo($request), 'auth' => [ 'user' => $request->user(), ], 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', ]; } + + /** + * @return array + */ + private function seo(Request $request): array + { + $appName = (string) config('app.name', 'applyst'); + $title = 'Track your job applications effectively'; + $description = 'Track job applications, monitor job search effectiveness, and keep follow-ups organized in one place.'; + $robots = 'index, follow'; + $ogImageVersion = file_exists(public_path('og.png')) ? filemtime(public_path('og.png')) : time(); + + if ($request->routeIs('home')) { + $title = 'Track your job applications effectively'; + $description = 'Track job applications, monitor your job search effectiveness, and keep follow-ups organized in one clean dashboard.'; + } + + if ( + $request->routeIs('dashboard') + || $request->routeIs('job-applications.*') + || $request->routeIs('settings.*') + || $request->routeIs('login') + || $request->routeIs('register') + || $request->routeIs('password.*') + || $request->routeIs('verification.*') + || $request->routeIs('two-factor.*') + || $request->routeIs('password.confirm') + ) { + $robots = 'noindex, nofollow'; + } + + return [ + 'title' => $title, + 'fullTitle' => $title.' - '.$appName, + 'description' => $description, + 'url' => $request->url(), + 'type' => 'website', + 'image' => asset('og.png').'?v='.$ogImageVersion, + 'siteName' => $appName, + 'robots' => $robots, + ]; + } } diff --git a/public/og.png b/public/og.png new file mode 100644 index 0000000..624b5df Binary files /dev/null and b/public/og.png differ diff --git a/resources/js/components/shared/seo-head.tsx b/resources/js/components/shared/seo-head.tsx new file mode 100644 index 0000000..8c5c341 --- /dev/null +++ b/resources/js/components/shared/seo-head.tsx @@ -0,0 +1,102 @@ +import { Head } from '@inertiajs/react'; + +type SeoHeadProps = { + title: string; + description?: string; + image?: string; + url?: string; + type?: 'website' | 'article'; + robots?: string; +}; + +const appName = import.meta.env.VITE_APP_NAME || 'applyst'; +const defaultDescription = + 'Track job applications, monitor job search effectiveness, and keep follow-ups organized in one place.'; + +function toAbsoluteUrl(value: string): string { + if (/^https?:\/\//i.test(value)) { + return value; + } + + if (typeof window === 'undefined') { + return value; + } + + return new URL(value, window.location.origin).toString(); +} + +export function SeoHead({ + title, + description = defaultDescription, + image = '/og.png', + url, + type = 'website', + robots = 'index, follow', +}: SeoHeadProps) { + const fullTitle = `${title} - ${appName}`; + const canonicalUrl = + url ?? + (typeof window === 'undefined' ? undefined : window.location.href); + const imageUrl = toAbsoluteUrl(image); + + return ( + + + + + {canonicalUrl ? ( + + ) : null} + + + + + {canonicalUrl ? ( + + ) : null} + + + + + + + + + ); +} diff --git a/resources/js/features/job-applications/components/job-application-show-timeline.tsx b/resources/js/features/job-applications/components/job-application-show-timeline.tsx index adcba80..1c02d04 100644 --- a/resources/js/features/job-applications/components/job-application-show-timeline.tsx +++ b/resources/js/features/job-applications/components/job-application-show-timeline.tsx @@ -1,5 +1,6 @@ import type { IconSvgElement } from '@hugeicons/react'; import { HugeiconsIcon } from '@hugeicons/react'; +import type { ReactNode } from 'react'; import { Badge } from '@/components/ui/badge'; import { Card, @@ -9,26 +10,68 @@ import { CardHeader, CardTitle, } from '@/components/ui/card'; -import { - JobApplicationStatusBadge, - getJobApplicationStatusMeta, -} from '@/features/job-applications/components/job-application-status'; -import type { JobApplicationStatusLog } from '@/features/job-applications/types'; +import { getJobApplicationStatusMeta } from '@/features/job-applications/components/job-application-status'; +import type { + JobApplicationStatus, + JobApplicationStatusLog, +} from '@/features/job-applications/types'; import { formatDate } from '@/features/job-applications/utils'; import { cn } from '@/lib/utils'; type JobApplicationShowTimelineProps = { statusLogs: JobApplicationStatusLog[]; processDurationLabel: string | null; + topAction?: ReactNode; }; function TimelineIcon({ icon }: { icon: IconSvgElement }) { return ; } +function getTimelineIconTone(status: JobApplicationStatus): string { + if (status === 'rejected') { + return 'border-destructive/45 bg-destructive/20 text-destructive'; + } + + if (status === 'accepted') { + return 'border-emerald-500/45 bg-emerald-500/20 text-emerald-600 dark:text-emerald-400'; + } + + if (status === 'offering') { + return 'border-sky-500/45 bg-sky-500/20 text-sky-600 dark:text-sky-400'; + } + + if (status === 'interview') { + return 'border-primary/45 bg-primary/20 text-primary'; + } + + return 'border-border/60 bg-muted/70 text-muted-foreground'; +} + +function getTimelineStatusTextTone(status: JobApplicationStatus): string { + if (status === 'rejected') { + return 'text-destructive'; + } + + if (status === 'accepted') { + return 'text-emerald-600 dark:text-emerald-400'; + } + + if (status === 'offering') { + return 'text-sky-600 dark:text-sky-400'; + } + + if (status === 'interview') { + return 'text-primary'; + } + + return 'text-foreground'; +} + export function JobApplicationShowTimeline({ statusLogs, processDurationLabel, + topAction, }: JobApplicationShowTimelineProps) { return ( @@ -47,6 +90,20 @@ export function JobApplicationShowTimeline({
    + {topAction ? ( +
  1. + + + + + +
    + {topAction} +
    + + +
  2. + ) : null} + {statusLogs.map((log, index) => { const statusMeta = getJobApplicationStatusMeta( log.to_status, @@ -55,23 +112,36 @@ export function JobApplicationShowTimeline({ return (
  3. {index < statusLogs.length - 1 ? ( - + ) : null} - + + +
    - +

    + {log.to_label} +

    {formatDate( log.changed_at ?? diff --git a/resources/js/pages/job-applications/show.tsx b/resources/js/pages/job-applications/show.tsx index 25e1569..f06a087 100644 --- a/resources/js/pages/job-applications/show.tsx +++ b/resources/js/pages/job-applications/show.tsx @@ -1,7 +1,11 @@ +import { Pencil } from '@hugeicons/core-free-icons'; +import { HugeiconsIcon } from '@hugeicons/react'; import { Head } from '@inertiajs/react'; +import { Button } from '@/components/ui/button'; import { JobApplicationShowHero } from '@/features/job-applications/components/job-application-show-hero'; import { JobApplicationShowNotesPanel } from '@/features/job-applications/components/job-application-show-notes-panel'; import { JobApplicationShowTimeline } from '@/features/job-applications/components/job-application-show-timeline'; +import { JobApplicationUpdateDialog } from '@/features/job-applications/components/job-application-update-dialog'; import type { JobApplicationDetail, JobApplicationStatusLabels, @@ -73,6 +77,25 @@ export default function JobApplicationsShow({ + + Update progress + + } + /> + } />
    diff --git a/resources/js/pages/landing.tsx b/resources/js/pages/landing.tsx index 5c5ac5b..101358d 100644 --- a/resources/js/pages/landing.tsx +++ b/resources/js/pages/landing.tsx @@ -1,4 +1,5 @@ -import { Head } from '@inertiajs/react'; +import { usePage } from '@inertiajs/react'; +import { SeoHead } from '@/components/shared/seo-head'; import { LandingCtaSection } from '@/features/landing/components/landing-cta-section'; import { LandingFeaturesSection } from '@/features/landing/components/landing-features-section'; import { LandingFooter } from '@/features/landing/components/landing-footer'; @@ -6,20 +7,32 @@ import { LandingHeader } from '@/features/landing/components/landing-header'; import { LandingHero } from '@/features/landing/components/landing-hero'; import { LandingProblemSection } from '@/features/landing/components/landing-problem-section'; +type LandingSeo = { + title: string; + description: string; + image: string; + url: string; + type: 'website' | 'article'; + robots: string; +}; + export default function Landing({ canRegister = true, }: { canRegister?: boolean; }) { + const { seo } = usePage().props as { seo: LandingSeo }; + return ( <> - - - - +
    diff --git a/resources/js/types/global.d.ts b/resources/js/types/global.d.ts index bd4459e..b995e3b 100644 --- a/resources/js/types/global.d.ts +++ b/resources/js/types/global.d.ts @@ -4,6 +4,16 @@ declare module '@inertiajs/core' { export interface InertiaConfig { sharedPageProps: { name: string; + seo: { + title: string; + fullTitle: string; + description: string; + url: string; + type: 'website' | 'article'; + image: string; + siteName: string; + robots: string; + }; auth: Auth; sidebarOpen: boolean; [key: string]: unknown; diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 04f3eeb..42af28b 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -34,6 +34,20 @@ $faviconIcoVersion = file_exists(public_path('favicon.ico')) ? filemtime(public_path('favicon.ico')) : time(); $faviconSvgVersion = file_exists(public_path('favicon.svg')) ? filemtime(public_path('favicon.svg')) : time(); $appleTouchIconVersion = file_exists(public_path('apple-touch-icon.png')) ? filemtime(public_path('apple-touch-icon.png')) : time(); + $ogImageVersion = file_exists(public_path('og.png')) ? filemtime(public_path('og.png')) : time(); + $appName = config('app.name', 'applyst'); + $defaultDescription = 'Track job applications, monitor job search effectiveness, and keep follow-ups organized in one place.'; + $defaultTitle = $appName.' - Track your job applications effectively'; + $defaultOgImage = asset('og.png').'?v='.$ogImageVersion; + $currentUrl = url()->current(); + $seo = $page['props']['seo'] ?? []; + $seoTitle = $seo['fullTitle'] ?? $defaultTitle; + $seoDescription = $seo['description'] ?? $defaultDescription; + $seoUrl = $seo['url'] ?? $currentUrl; + $seoType = $seo['type'] ?? 'website'; + $seoImage = $seo['image'] ?? $defaultOgImage; + $seoSiteName = $seo['siteName'] ?? $appName; + $seoRobots = $seo['robots'] ?? 'index, follow'; @endphp @@ -46,7 +60,22 @@ @viteReactRefresh @vite(['resources/css/app.css', 'resources/js/app.tsx', "resources/js/pages/{$page['component']}.tsx"]) - {{ config('app.name', 'applyst') }} + {{ $seoTitle }} + + + + + + + + + + + + + + + diff --git a/tests/Feature/LandingPageTest.php b/tests/Feature/LandingPageTest.php index 6b5593f..d602646 100644 --- a/tests/Feature/LandingPageTest.php +++ b/tests/Feature/LandingPageTest.php @@ -20,4 +20,19 @@ public function test_landing_page_can_be_rendered(): void ->where('canRegister', Features::enabled(Features::registration())) ); } + + public function test_landing_page_contains_default_seo_meta_tags(): void + { + $canonicalUrl = rtrim(config('app.url'), '/'); + $fullTitle = 'Track your job applications effectively - '.config('app.name'); + + $this->get(route('home')) + ->assertOk() + ->assertSee('name="description" content="Track job applications, monitor your job search effectiveness, and keep follow-ups organized in one clean dashboard."', false) + ->assertSee('property="og:type" content="website"', false) + ->assertSee('property="og:title" content="'.$fullTitle.'"', false) + ->assertSee('property="og:image" content="'.$canonicalUrl.'/og.png?v=', false) + ->assertSee('name="twitter:card" content="summary_large_image"', false) + ->assertSee('rel="canonical" href="'.$canonicalUrl.'"', false); + } }