diff --git a/src/api/jobs.ts b/src/api/jobs.ts index dbf48a6..a543a6a 100644 --- a/src/api/jobs.ts +++ b/src/api/jobs.ts @@ -1,60 +1,125 @@ -import api from '@/api/client' +import api from "@/api/client"; export interface Technology { - id: number - name: string + id: number; + name: string; } export interface Category { - id: number - name: string - slug: string + id: number; + name: string; + slug: string; +} + +export interface Location { + id: number; + name: string; + slug: string; } export interface JobListing { - id: number - title: string - description: string | null - salary_range: string | null - work_type: 'remote' | 'onsite' | 'hybrid' | null - location: string | null - application_deadline: string | null - category: Category | null - technologies: Technology[] + id: number; + title: string; + description: string | null; + salary_range: string | null; + work_type: "remote" | "onsite" | "hybrid" | null; + location: string | null; + experience_level: "entry" | "mid" | "senior" | "lead" | null; + application_deadline: string | null; + created_at: string | null; + category: Category | null; + technologies: Technology[]; employer: { - company_name: string - logo_url: string | null - } + company_name: string; + logo_url: string | null; + }; } export interface JobDetail extends JobListing { - requirements: string | null - views_count: number - created_at: string | null + requirements: string | null; + views_count: number; employer: { - company_name: string - logo_url: string | null - bio: string | null - website: string | null - } + company_name: string; + logo_url: string | null; + bio: string | null; + website: string | null; + }; +} + +export interface JobFilters { + q?: string; + sort?: "newest" | "deadline"; + page?: number; + per_page?: number; + experience_level?: "entry" | "mid" | "senior" | "lead"; + location?: string; + work_type?: "remote" | "onsite" | "hybrid"; + /** Category slug or numeric id as string */ + category?: string; + /** ISO date string YYYY-MM-DD β€” filter jobs posted on/after this date */ + date_from?: string; +} + +export interface PaginatedJobsResponse { + data: JobListing[]; + meta: { + current_page: number; + last_page: number; + per_page: number; + total: number; + }; } -export async function fetchJobs(page = 1): Promise<{ data: JobListing[]; meta: { current_page: number; last_page: number; total: number } }> { - const { data } = await api.get('/v1/jobs', { params: { page } }) - return data +function buildParams(filters: JobFilters): Record { + const params: Record = {}; + if (filters.q) params.q = filters.q; + if (filters.sort) params.sort = filters.sort; + if (filters.page) params.page = filters.page; + if (filters.per_page) params.per_page = filters.per_page; + if (filters.experience_level) + params.experience_level = filters.experience_level; + if (filters.location) params.location = filters.location; + if (filters.work_type) params.work_type = filters.work_type; + if (filters.category) params.category = filters.category; + if (filters.date_from) params.date_from = filters.date_from; + return params; +} + +/** Fetch active jobs for the candidate home page (/v1/jobs) */ +export async function fetchJobs( + filters: JobFilters = {}, +): Promise { + const { data } = await api.get("/v1/jobs", { params: buildParams(filters) }); + return data; +} + +/** Full search with all filters (/v1/search/jobs) */ +export async function fetchSearchJobs( + filters: JobFilters = {}, +): Promise { + const { data } = await api.get("/v1/search/jobs", { + params: buildParams(filters), + }); + return data; } export async function fetchJob(id: number): Promise { - const { data } = await api.get(`/v1/jobs/${id}`) - return data.data + const { data } = await api.get(`/v1/jobs/${id}`); + return data.data; } export async function fetchCategories(): Promise { - const { data } = await api.get('/v1/categories') - return data.data + const { data } = await api.get("/v1/categories"); + return data.data; } export async function fetchTechnologies(): Promise { - const { data } = await api.get('/v1/technologies') - return data.data + const { data } = await api.get("/v1/technologies"); + return data.data; +} + +/** Location reference list for autocomplete (public, no auth required) */ +export async function fetchLocations(): Promise { + const { data } = await api.get("/v1/search/locations"); + return data.data; } diff --git a/src/components/search/ActiveFilters.vue b/src/components/search/ActiveFilters.vue new file mode 100644 index 0000000..ef8d563 --- /dev/null +++ b/src/components/search/ActiveFilters.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/src/components/search/FilterSection.vue b/src/components/search/FilterSection.vue new file mode 100644 index 0000000..12e9766 --- /dev/null +++ b/src/components/search/FilterSection.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/src/components/search/FilterSidebar.vue b/src/components/search/FilterSidebar.vue new file mode 100644 index 0000000..8823ea7 --- /dev/null +++ b/src/components/search/FilterSidebar.vue @@ -0,0 +1,476 @@ + + + + + diff --git a/src/composables/useJobSearch.ts b/src/composables/useJobSearch.ts new file mode 100644 index 0000000..8bac2ca --- /dev/null +++ b/src/composables/useJobSearch.ts @@ -0,0 +1,90 @@ +import { watch, onMounted } from "vue"; +import { useRoute, useRouter } from "vue-router"; +import { useSearchStore } from "@/stores/search"; + +/** + * Composable that wires the search store to URL query params. + * Supports shareable links β€” all filter state is reflected in the URL. + * Used by JobSearchView.vue. + */ +export function useJobSearch() { + const route = useRoute(); + const router = useRouter(); + const store = useSearchStore(); + + let debounceTimer: ReturnType | null = null; + + // ── URL sync ────────────────────────────────────────────────────────────── + + function syncToUrl() { + const q: Record = {}; + if (store.query.trim()) q.q = store.query.trim(); + if (store.sort !== "newest") q.sort = store.sort; + if (store.filters.location) q.location = store.filters.location; + if (store.filters.work_type) q.work_type = store.filters.work_type; + if (store.filters.experience_level) + q.experience_level = store.filters.experience_level; + if (store.filters.category) q.category = store.filters.category; + if (store.filters.date_from) q.date_from = store.filters.date_from; + router.replace({ query: q }); + } + + function initFromUrl() { + const q = route.query; + store.query = (q.q as string) ?? ""; + store.sort = q.sort === "deadline" ? "deadline" : "newest"; + store.filters.location = (q.location as string) ?? ""; + store.filters.work_type = ((q.work_type as string) ?? + "") as typeof store.filters.work_type; + store.filters.experience_level = ((q.experience_level as string) ?? + "") as typeof store.filters.experience_level; + store.filters.category = (q.category as string) ?? ""; + store.filters.date_from = (q.date_from as string) ?? ""; + } + + // ── Scheduling ──────────────────────────────────────────────────────────── + + function scheduleSearch(page = 1) { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + syncToUrl(); + store.loadJobs(page); + }, 350); + } + + function goToPage(page: number) { + syncToUrl(); + store.loadJobs(page); + window.scrollTo({ top: 0, behavior: "smooth" }); + } + + // ── Watchers ────────────────────────────────────────────────────────────── + + watch( + () => store.query, + () => scheduleSearch(1), + ); + + watch( + () => store.sort, + () => { + syncToUrl(); + store.loadJobs(1); + }, + ); + + watch( + () => ({ ...store.filters }), + () => scheduleSearch(1), + { deep: true }, + ); + + // ── Bootstrap ───────────────────────────────────────────────────────────── + + onMounted(async () => { + initFromUrl(); + await Promise.all([store.loadOptions(), store.loadJobs(1)]); + }); + + return { store, goToPage }; +} diff --git a/src/router/index.ts b/src/router/index.ts index f9d8660..dd5df42 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -23,37 +23,58 @@ const router = createRouter({ }, meta: { requiresAuth: true, title: "Dashboard" }, }, - // Candidate home – job listings + // Candidate home – job listings with sidebar filters { path: "/jobs", name: "candidate-jobs", component: () => import("@/views/CandidateJobsView.vue"), - meta: { requiresAuth: true, role: "candidate" as UserRole, title: "Find Jobs" }, + meta: { + requiresAuth: true, + role: "candidate" as UserRole, + title: "Find Jobs", + }, }, { path: "/jobs/:jobId", name: "job-detail", component: () => import("@/views/jobs/JobDetailView.vue"), - meta: { requiresAuth: true, role: "candidate" as UserRole, title: "Job Detail" }, + meta: { + requiresAuth: true, + role: "candidate" as UserRole, + title: "Job Detail", + }, props: true, }, { path: "/jobs/:jobId/apply", name: "candidate-apply", component: () => import("@/views/candidate/ApplyView.vue"), - meta: { requiresAuth: true, role: "candidate" as UserRole, title: "Apply" }, + meta: { + requiresAuth: true, + role: "candidate" as UserRole, + title: "Apply", + }, }, { path: "/applications/:applicationId", name: "candidate-application-detail", - component: () => import("@/views/candidate/CandidateApplicationDetailView.vue"), - meta: { requiresAuth: true, role: "candidate" as UserRole, title: "Application Detail" }, + component: () => + import("@/views/candidate/CandidateApplicationDetailView.vue"), + meta: { + requiresAuth: true, + role: "candidate" as UserRole, + title: "Application Detail", + }, }, { path: "/applications", name: "candidate-applications", component: () => import("@/views/candidate/ApplicationsView.vue"), - meta: { requiresAuth: true, role: "candidate" as UserRole, title: "My Applications" }, + meta: { + requiresAuth: true, + role: "candidate" as UserRole, + title: "My Applications", + }, }, { path: "/profile/edit", @@ -155,19 +176,31 @@ const router = createRouter({ path: "/employer/jobs", name: "employer-jobs", component: () => import("@/views/employer/EmployerJobsView.vue"), - meta: { requiresAuth: true, role: "employer" as UserRole, title: "My Jobs" }, + meta: { + requiresAuth: true, + role: "employer" as UserRole, + title: "My Jobs", + }, }, { path: "/employer/jobs/create", name: "employer-job-create", component: () => import("@/views/employer/CreateJobView.vue"), - meta: { requiresAuth: true, role: "employer" as UserRole, title: "Post New Job" }, + meta: { + requiresAuth: true, + role: "employer" as UserRole, + title: "Post New Job", + }, }, { path: "/employer/jobs/:jobId/edit", name: "employer-job-edit", component: () => import("@/views/employer/EditJobView.vue"), - meta: { requiresAuth: true, role: "employer" as UserRole, title: "Edit Job" }, + meta: { + requiresAuth: true, + role: "employer" as UserRole, + title: "Edit Job", + }, props: true, }, { diff --git a/src/stores/search.ts b/src/stores/search.ts new file mode 100644 index 0000000..7c136e8 --- /dev/null +++ b/src/stores/search.ts @@ -0,0 +1,101 @@ +import { defineStore } from "pinia"; +import { ref, computed } from "vue"; +import { fetchSearchJobs, fetchCategories, fetchLocations } from "@/api/jobs"; +import type { JobListing, Category, Location, JobFilters } from "@/api/jobs"; + +export interface SearchFilters { + location: string; + work_type: "remote" | "onsite" | "hybrid" | ""; + experience_level: "entry" | "mid" | "senior" | "lead" | ""; + /** Category slug */ + category: string; + /** YYYY-MM-DD */ + date_from: string; +} + +const EMPTY_FILTERS: SearchFilters = { + location: "", + work_type: "", + experience_level: "", + category: "", + date_from: "", +}; + +export const useSearchStore = defineStore("search", () => { + const query = ref(""); + const sort = ref<"newest" | "deadline">("newest"); + const filters = ref({ ...EMPTY_FILTERS }); + + const jobs = ref([]); + const meta = ref({ current_page: 1, last_page: 1, per_page: 20, total: 0 }); + const isLoading = ref(false); + + const categories = ref([]); + const locations = ref([]); + const optionsLoaded = ref(false); + + const activeFilterCount = computed( + () => Object.values(filters.value).filter(Boolean).length, + ); + const hasActiveFilters = computed( + () => activeFilterCount.value > 0 || !!query.value.trim(), + ); + + async function loadJobs(page = 1) { + isLoading.value = true; + try { + const params: JobFilters = { + page, + sort: sort.value, + q: query.value.trim() || undefined, + experience_level: + (filters.value.experience_level as JobFilters["experience_level"]) || + undefined, + location: filters.value.location || undefined, + work_type: + (filters.value.work_type as JobFilters["work_type"]) || undefined, + category: filters.value.category || undefined, + date_from: filters.value.date_from || undefined, + }; + const res = await fetchSearchJobs(params); + jobs.value = res.data; + meta.value = res.meta; + } finally { + isLoading.value = false; + } + } + + async function loadOptions() { + if (optionsLoaded.value) return; + const [cats, locs] = await Promise.all([ + fetchCategories(), + fetchLocations(), + ]); + categories.value = cats; + locations.value = locs; + optionsLoaded.value = true; + } + + function resetFilters() { + filters.value = { ...EMPTY_FILTERS }; + query.value = ""; + sort.value = "newest"; + } + + return { + query, + sort, + filters, + jobs, + meta, + isLoading, + categories, + locations, + optionsLoaded, + activeFilterCount, + hasActiveFilters, + loadJobs, + loadOptions, + resetFilters, + }; +}); diff --git a/src/views/CandidateJobsView.vue b/src/views/CandidateJobsView.vue index d900c50..1a9cb1f 100644 --- a/src/views/CandidateJobsView.vue +++ b/src/views/CandidateJobsView.vue @@ -1,279 +1,562 @@ diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index d434ed4..4dcfece 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -1,21 +1,35 @@