Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 100 additions & 35 deletions src/api/jobs.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | number> {
const params: Record<string, string | number> = {};
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<PaginatedJobsResponse> {
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<PaginatedJobsResponse> {
const { data } = await api.get("/v1/search/jobs", {
params: buildParams(filters),
});
return data;
}

export async function fetchJob(id: number): Promise<JobDetail> {
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<Category[]> {
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<Technology[]> {
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<Location[]> {
const { data } = await api.get("/v1/search/locations");
return data.data;
}
159 changes: 159 additions & 0 deletions src/components/search/ActiveFilters.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
<template>
<div v-if="chips.length > 0" class="active-filters" role="list">
<span class="active-filters__label">Active:</span>
<button
v-for="chip in chips"
:key="chip.key"
type="button"
class="filter-chip"
:aria-label="`Remove ${chip.label} filter`"
@click="chip.remove()"
>
{{ chip.label }}
<span class="material-symbols-outlined filter-chip__x">close</span>
</button>
<button
v-if="chips.length > 1"
type="button"
class="filter-chip filter-chip--clear-all"
@click="store.resetFilters()"
>
Clear all
</button>
</div>
</template>

<script setup lang="ts">
import { computed } from "vue";
import { useSearchStore } from "@/stores/search";

const store = useSearchStore();

interface Chip {
key: string;
label: string;
remove: () => void;
}

const WORK_TYPE_LABELS: Record<string, string> = {
remote: "Remote",
onsite: "On-site",
hybrid: "Hybrid",
};

const EXPERIENCE_LABELS: Record<string, string> = {
entry: "Entry",
mid: "Mid",
senior: "Senior",
lead: "Lead",
};

const chips = computed<Chip[]>(() => {
const result: Chip[] = [];

if (store.query.trim()) {
result.push({
key: "q",
label: `"${store.query.trim()}"`,
remove: () => (store.query = ""),
});
}
if (store.filters.location) {
result.push({
key: "location",
label: `📍 ${store.filters.location}`,
remove: () => (store.filters.location = ""),
});
}
if (store.filters.work_type) {
result.push({
key: "work_type",
label:
WORK_TYPE_LABELS[store.filters.work_type] ?? store.filters.work_type,
remove: () => (store.filters.work_type = ""),
});
}
if (store.filters.experience_level) {
result.push({
key: "experience_level",
label:
EXPERIENCE_LABELS[store.filters.experience_level] ??
store.filters.experience_level,
remove: () => (store.filters.experience_level = ""),
});
}
if (store.filters.category) {
const cat = store.categories.find((c) => c.slug === store.filters.category);
result.push({
key: "category",
label: cat?.name ?? store.filters.category,
remove: () => (store.filters.category = ""),
});
}
if (store.filters.date_from) {
result.push({
key: "date_from",
label: `After ${store.filters.date_from}`,
remove: () => (store.filters.date_from = ""),
});
}

return result;
});
</script>

<style scoped>
.active-filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}

.active-filters__label {
font-size: 0.78rem;
color: var(--on-surface-variant);
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
}

.filter-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.7rem;
border-radius: 9999px;
border: 1px solid rgba(255, 183, 125, 0.25);
background: rgba(255, 183, 125, 0.08);
color: var(--primary-dim);
font: inherit;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition:
background 0.2s,
border-color 0.2s;
}

.filter-chip:hover {
background: rgba(255, 183, 125, 0.18);
border-color: rgba(255, 183, 125, 0.45);
}

.filter-chip__x {
font-size: 0.85rem;
opacity: 0.7;
}

.filter-chip--clear-all {
border-color: rgba(255, 100, 100, 0.25);
background: rgba(255, 100, 100, 0.07);
color: rgba(255, 150, 150, 0.9);
}

.filter-chip--clear-all:hover {
background: rgba(255, 100, 100, 0.15);
border-color: rgba(255, 100, 100, 0.5);
}
</style>
Loading