diff --git a/apps/web/public/svg/sidebarFilter.svg b/apps/web/public/svg/sidebarFilter.svg
new file mode 100644
index 00000000..60b16827
--- /dev/null
+++ b/apps/web/public/svg/sidebarFilter.svg
@@ -0,0 +1,3 @@
+
diff --git a/apps/web/src/app/(pages)/(dashboard)/(roles)/page.tsx b/apps/web/src/app/(pages)/(dashboard)/(roles)/page.tsx
index f09c2274..b204ae1a 100644
--- a/apps/web/src/app/(pages)/(dashboard)/(roles)/page.tsx
+++ b/apps/web/src/app/(pages)/(dashboard)/(roles)/page.tsx
@@ -2,12 +2,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
+import Image from "next/image";
import { ChevronDown } from "lucide-react";
import type { CompanyType, RoleType } from "@cooper/db/schema";
import { cn, Pagination } from "@cooper/ui";
import { Button } from "@cooper/ui/button";
-import { Chip } from "@cooper/ui/chip";
import {
DropdownMenu,
DropdownMenuContent,
@@ -28,7 +28,9 @@ import NoResults from "~/app/_components/no-results";
import { RoleCardPreview } from "~/app/_components/reviews/role-card-preview";
import { RoleInfo } from "~/app/_components/reviews/role-info";
import SearchFilter from "~/app/_components/search/search-filter";
+import SidebarFilter from "~/app/_components/filters/sidebar-filter";
import { api } from "~/trpc/react";
+import RoleTypeSelector from "~/app/_components/filters/role-type-selector";
interface FilterState {
industries: string[];
@@ -36,6 +38,9 @@ interface FilterState {
jobTypes: string[];
hourlyPay: { min: number; max: number };
ratings: string[];
+ workModels?: string[];
+ overtimeWork?: string[];
+ companyCulture?: string[];
}
// Helper function to create URL-friendly slugs (still needed for URL generation)
@@ -56,6 +61,8 @@ export default function Roles() {
const router = useRouter();
const compare = useCompare();
+ const [isSidebarOpen, setIsSidebarOpen] = useState(false);
+
const [selectedType, setSelectedType] = useState<
"roles" | "companies" | "all"
>("all");
@@ -70,6 +77,9 @@ export default function Roles() {
jobTypes: [],
hourlyPay: { min: 0, max: 0 },
ratings: [],
+ workModels: [],
+ overtimeWork: [],
+ companyCulture: [],
});
const rolesAndCompaniesPerPage = 10;
@@ -433,8 +443,23 @@ export default function Roles() {
-
-
+
+
+
{compare.isCompareMode && selectedRole && (
@@ -494,23 +519,14 @@ export default function Roles() {
-
- setSelectedType("all")}
- selected={selectedType === "all"}
- />
- setSelectedType("roles")}
- label={`Jobs (${rolesAndCompanies.data.totalRolesCount})`}
- selected={selectedType === "roles"}
- />
- setSelectedType("companies")}
- label={`Companies (${rolesAndCompanies.data.totalCompanyCount})`}
- selected={selectedType === "companies"}
- />
-
+
{rolesAndCompanies.data.items.map((item, i) => {
if (item.type === "role") {
@@ -653,11 +669,6 @@ export default function Roles() {
!showRoleInfo && "hidden md:block", // Hide on mobile if RoleCardPreview is visible
)}
>
- {selectedRole && !compare.isCompareMode && (
-
-
-
- )}
{selectedRole ? (
compare.isCompareMode ? (
@@ -678,6 +689,20 @@ export default function Roles() {
)}
{rolesAndCompanies.isPending &&
}
+
+
setIsSidebarOpen(false)}
+ filters={appliedFilters}
+ onFilterChange={handleFilterChange}
+ selectedType={selectedType}
+ onSelectedTypeChange={setSelectedType}
+ data={{
+ totalRolesCount: rolesAndCompanies.data?.totalRolesCount ?? 0,
+ totalCompanyCount: rolesAndCompanies.data?.totalCompanyCount ?? 0,
+ }}
+ isLoading={rolesAndCompanies.isFetching}
+ />
>
);
}
diff --git a/apps/web/src/app/_components/filters/dropdown-filter.tsx b/apps/web/src/app/_components/filters/dropdown-filter.tsx
index aac2738d..83604fdb 100644
--- a/apps/web/src/app/_components/filters/dropdown-filter.tsx
+++ b/apps/web/src/app/_components/filters/dropdown-filter.tsx
@@ -1,13 +1,11 @@
"use client";
-import { useState, useEffect } from "react";
+import { useMemo, useState } from "react";
import { ChevronDown, X } from "lucide-react";
import { cn } from "@cooper/ui";
import Image from "next/image";
-import { Input } from "../themed/onboarding/input";
-
import {
DropdownMenu,
DropdownMenuContent,
@@ -15,14 +13,9 @@ import {
DropdownMenuTrigger,
} from "@cooper/ui/dropdown-menu";
import { Button } from "@cooper/ui/button";
-import { Checkbox } from "@cooper/ui/checkbox";
-import Autocomplete from "@cooper/ui/autocomplete";
-interface FilterOption {
- id: string;
- label: string;
- value?: string;
-}
+import FilterBody from "./filter-body";
+import type { FilterOption, FilterVariant } from "./filter-body";
interface DropdownFilterProps {
title: string;
@@ -30,18 +23,10 @@ interface DropdownFilterProps {
selectedOptions: string[];
onSelectionChange?: (selected: string[]) => void;
placeholder?: string;
- filterType?: "autocomplete" | "checkbox" | "range" | "rating" | "location";
+ filterType?: FilterVariant;
minValue?: number;
maxValue?: number;
onRangeChange?: (min: number, max: number) => void;
-}
-
-interface DropdownFilterProps {
- title: string;
- options: FilterOption[];
- selectedOptions: string[];
- onSelectionChange?: (selected: string[]) => void;
- placeholder?: string;
onSearchChange?: (search: string) => void;
isLoadingOptions?: boolean;
}
@@ -58,109 +43,44 @@ export default function DropdownFilter({
onSearchChange,
}: DropdownFilterProps) {
const [isOpen, setIsOpen] = useState(false);
- const [searchTerm, setSearchTerm] = useState("");
- const [localMin, setLocalMin] = useState(minValue?.toString() ?? "");
- const [localMax, setLocalMax] = useState(maxValue?.toString() ?? "");
- const [rangeError, setRangeError] = useState(null);
- // Trigger search callback when user types 3+ characters for location filter
- useEffect(() => {
- if (
- filterType === "autocomplete" &&
- onSearchChange &&
- searchTerm.length >= 3
- ) {
- onSearchChange(searchTerm.slice(0, 3).toLowerCase());
+ const isFiltering = useMemo(() => {
+ if (filterType === "range") {
+ return Boolean((minValue && minValue > 0) ?? (maxValue && maxValue > 0));
}
- }, [searchTerm, filterType, onSearchChange]);
-
- const isFiltering =
- selectedOptions.length > 0 ||
- (filterType === "range" && (localMin || localMax));
-
- const handleToggleOption = (optionId: string) => {
- onSelectionChange?.(
- selectedOptions.includes(optionId)
- ? selectedOptions.filter((id) => id !== optionId)
- : [...selectedOptions, optionId],
- );
- };
+ return selectedOptions.length > 0;
+ }, [filterType, selectedOptions.length, minValue, maxValue]);
const handleClear = () => {
onSelectionChange?.([]);
if (filterType === "range" && onRangeChange) {
- setLocalMin("");
- setLocalMax("");
- setRangeError(null);
onRangeChange(0, 0);
}
};
- const handleRangeApply = () => {
- if (!onRangeChange) return;
-
- // Validate before applying
- const min = localMin ? parseFloat(localMin) : NaN;
- const max = localMax ? parseFloat(localMax) : NaN;
-
- if (!isNaN(min) && !isNaN(max) && min >= max) {
- setRangeError("Minimum must be less than maximum");
- return;
- }
-
- // Coerce defaults only when one side is empty
- const appliedMin = !isNaN(min) ? min : 0;
- const appliedMax = !isNaN(max) ? max : 100;
-
- setRangeError(null);
- onRangeChange(appliedMin, appliedMax);
- return;
- };
-
- // Validate range live so we can show helpful messaging while typing
- useEffect(() => {
- const min = localMin ? parseFloat(localMin) : NaN;
- const max = localMax ? parseFloat(localMax) : NaN;
-
- if (!isNaN(min) && !isNaN(max)) {
- if (min >= max) {
- setRangeError("Minimum must be less than maximum");
- } else {
- setRangeError(null);
- }
- } else {
- // If one or both are empty/invalid, clear the error (we validate on apply)
- setRangeError(null);
- }
- }, [localMin, localMax]);
-
- const displayText = (() => {
+ const displayText = useMemo(() => {
if (filterType === "range") {
- if (localMin && localMax) {
- return `$${localMin}-${localMax}/hr`;
- } else if (localMin) {
- return `$${localMin}/hr+`;
- } else if (localMax) {
- return `Up to $${localMax}/hr`;
- } else {
- return title;
- }
+ const min = minValue ?? 0;
+ const max = maxValue ?? 0;
+
+ if (min > 0 && max > 0) return `$${min}-${max}/hr`;
+ if (min > 0) return `$${min}/hr+`;
+ if (max > 0) return `Up to $${max}/hr`;
+ return title;
}
if (filterType === "rating") {
if (selectedOptions.length === 0) return title;
const minRating = Math.min(...selectedOptions.map(Number));
const maxRating = Math.max(...selectedOptions.map(Number));
- if (minRating === maxRating) {
- return `${minRating}.0+ stars`;
- } else {
- return (
-
- {minRating}.0 - {maxRating}.0{" "}
-
-
- );
- }
+ if (minRating === maxRating) return `${minRating}.0+ stars`;
+
+ return (
+
+ {minRating}.0 - {maxRating}.0{" "}
+
+
+ );
}
if (selectedOptions.length === 0) return title;
@@ -171,207 +91,8 @@ export default function DropdownFilter({
const additionalCount =
selectedOptions.length > 1 ? ` +${selectedOptions.length - 1}` : "";
return `${firstLabel}${additionalCount}`;
- })();
-
- const filteredOptions = options.filter((option) =>
- option.label.toLowerCase().includes(searchTerm.toLowerCase()),
- );
+ }, [filterType, maxValue, minValue, options, selectedOptions, title]);
- const renderContent = () => {
- if (filterType === "range") {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
- $
-
- setLocalMin(e.target.value)}
- className={cn(
- "h-9 border-cooper-gray-150 border-[1px] text-sm text-cooper-gray-400 pl-5",
- rangeError ? "border-red-500" : "",
- )}
- onBlur={handleRangeApply}
- onKeyDown={(e) => e.key === "Enter" && handleRangeApply()}
- />
-
-
-
-
- $
-
- setLocalMax(e.target.value)}
- className={cn(
- "h-9 border-cooper-gray-150 border-[1px] text-sm text-cooper-gray-400 pl-5",
- rangeError ? "border-red-500" : "",
- )}
- onBlur={handleRangeApply}
- onKeyDown={(e) => e.key === "Enter" && handleRangeApply()}
- />
-
-
- {rangeError && (
-
{rangeError}
- )}
-
- );
- }
-
- if (filterType === "rating") {
- const minRating =
- selectedOptions.length > 0
- ? Math.min(...selectedOptions.map(Number))
- : 0;
- const maxRating =
- selectedOptions.length > 0
- ? Math.max(...selectedOptions.map(Number))
- : 0;
-
- const handleRatingClick = (rating: number) => {
- if (selectedOptions.length === 0) {
- // First click - select this rating
- onSelectionChange?.([rating.toString()]);
- } else if (selectedOptions.length === 1) {
- const current = Number(selectedOptions[0]);
- if (rating === current) {
- // Clicking same rating - deselect
- onSelectionChange?.([]);
- } else {
- // Second click - create range
- const min = Math.min(current, rating);
- const max = Math.max(current, rating);
- const range = [];
- for (let i = min; i <= max; i++) {
- range.push(i.toString());
- }
- onSelectionChange?.(range);
- }
- } else {
- // Range exists - clicking sets new single rating
- onSelectionChange?.([rating.toString()]);
- }
- };
-
- return (
-
- {[1, 2, 3, 4, 5].map((rating, index) => {
- const isInRange =
- rating >= minRating &&
- rating <= maxRating &&
- selectedOptions.length > 0;
-
- return (
-
- );
- })}
-
- );
- }
-
- if (filterType === "autocomplete") {
- return (
-
-
({
- value: option.id,
- label: option.label,
- }))}
- value={selectedOptions}
- onChange={(selected) => onSelectionChange?.(selected)}
- placeholder={`Search by ${title === "Industry" ? "industry" : "city or state"}`}
- />
-
- );
- }
- if (filterType === "location") {
- return (
-
-
({
- value: option.id,
- label: option.label,
- }))}
- value={selectedOptions}
- onChange={(selected) => {
- onSelectionChange?.(selected);
- }}
- placeholder={`Search by city or state...`}
- onSearchChange={onSearchChange}
- />
-
- );
- } else {
- return (
-
- {options.length > 5 && (
-
setSearchTerm(e.target.value)}
- className="h-9"
- />
- )}
-
- {filteredOptions.map((option) => (
-
- handleToggleOption(option.id)}
- />
-
-
- ))}
-
-
- );
- }
- };
return (
@@ -409,7 +130,18 @@ export default function DropdownFilter({
- {renderContent()}
+
+
);
diff --git a/apps/web/src/app/_components/filters/dropdown-filters-bar.tsx b/apps/web/src/app/_components/filters/dropdown-filters-bar.tsx
index 4d83d3a5..0689b45b 100644
--- a/apps/web/src/app/_components/filters/dropdown-filters-bar.tsx
+++ b/apps/web/src/app/_components/filters/dropdown-filters-bar.tsx
@@ -1,11 +1,12 @@
"use client";
-import { useState, useEffect } from "react";
+import { useState, useEffect, useMemo } from "react";
import { api } from "~/trpc/react";
-import { industryOptions } from "../onboarding/constants";
+import { industryOptions, jobTypeOptions } from "../onboarding/constants";
import DropdownFilter from "./dropdown-filter";
import { abbreviatedStateName } from "~/utils/locationHelpers";
+import type { LocationType } from "@cooper/db/schema";
interface FilterState {
industries: string[];
@@ -16,22 +17,14 @@ interface FilterState {
}
interface DropdownFiltersBarProps {
+ filters: FilterState;
onFilterChange: (filters: FilterState) => void;
- jobTypes?: { id: string; label: string }[];
}
export default function DropdownFiltersBar({
+ filters,
onFilterChange,
- jobTypes = [],
}: DropdownFiltersBarProps) {
- const [filters, setFilters] = useState({
- industries: [],
- locations: [],
- jobTypes: [],
- hourlyPay: { min: 0, max: 0 },
- ratings: [],
- });
-
const [searchTerm, setSearchTerm] = useState("");
const [prefix, setPrefix] = useState("");
@@ -56,10 +49,26 @@ export default function DropdownFiltersBar({
...filters,
[key]: value,
};
- setFilters(newFilters);
onFilterChange(newFilters);
};
+ // fetch each selected location so we can show labels immediately
+ //this small part is like completely vibecoded btw :skull:
+ const selectedLocationQueries = api.useQueries((t) =>
+ filters.locations.map((id) =>
+ t.location.getById(
+ { id },
+ {
+ enabled: !!id,
+ },
+ ),
+ ),
+ );
+
+ const selectedLocations = selectedLocationQueries
+ .map((q) => q.data)
+ .filter((loc): loc is LocationType => Boolean(loc));
+
// Industry options from your schema
const industryOptionsWithId = Object.entries(industryOptions).map(
([_value, label]) => ({
@@ -69,21 +78,31 @@ export default function DropdownFiltersBar({
}),
);
- // Location options
- const locationOptions = locationsToUpdate.data
- ? locationsToUpdate.data.map((loc) => ({
+ const locationOptions = useMemo(() => {
+ const fromSelected = selectedLocations.map((loc) => ({
+ id: loc.id,
+ label: `${loc.city}${loc.state ? `, ${abbreviatedStateName(loc.state)}` : ""}`,
+ }));
+
+ const fromPrefix =
+ locationsToUpdate.data?.map((loc) => ({
id: loc.id,
label: `${loc.city}${loc.state ? `, ${abbreviatedStateName(loc.state)}` : ""}`,
- }))
- : [];
+ })) ?? [];
+
+ // merge + dedupe by id
+ const map = new Map();
+ for (const opt of [...fromSelected, ...fromPrefix]) map.set(opt.id, opt);
+
+ return Array.from(map.values());
+ }, [selectedLocations, locationsToUpdate.data]);
- // Job type options - you'll need to define these based on your schema
- const jobTypeOptions = jobTypes.length
- ? jobTypes
- : [
- { id: "Co-op", label: "Co-op" },
- { id: "Internship", label: "Internship" },
- ];
+ // Job type options
+ const jobTypeOptionsWithId = jobTypeOptions.map((jobType) => ({
+ id: jobType.value,
+ label: jobType.label,
+ value: jobType.value,
+ }));
return (
@@ -111,7 +130,7 @@ export default function DropdownFiltersBar({
handleFilterChange("jobTypes", selected)
diff --git a/apps/web/src/app/_components/filters/filter-body.tsx b/apps/web/src/app/_components/filters/filter-body.tsx
new file mode 100644
index 00000000..d286d523
--- /dev/null
+++ b/apps/web/src/app/_components/filters/filter-body.tsx
@@ -0,0 +1,328 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import Image from "next/image";
+
+import { cn } from "@cooper/ui";
+import { Checkbox } from "@cooper/ui/checkbox";
+import Autocomplete from "@cooper/ui/autocomplete";
+import { Input } from "../themed/onboarding/input";
+
+export interface FilterOption {
+ id: string;
+ label: string;
+ value?: string;
+}
+
+export type FilterVariant =
+ | "autocomplete"
+ | "checkbox"
+ | "range"
+ | "rating"
+ | "location";
+
+interface FilterBodyProps {
+ variant: FilterVariant;
+ title: string;
+ options: FilterOption[];
+ selectedOptions: string[];
+ onSelectionChange?: (selected: string[]) => void;
+ placeholder?: string;
+ minValue?: number;
+ maxValue?: number;
+ onRangeChange?: (min: number, max: number) => void;
+ onSearchChange?: (search: string) => void;
+ isLoadingOptions?: boolean;
+}
+
+/**
+ * Switch/router component that returns the correct filter-body subcomponent.
+ * This is extracted from DropdownFilter's previous renderContent().
+ */
+export default function FilterBody(props: FilterBodyProps) {
+ const { variant } = props;
+
+ switch (variant) {
+ case "range":
+ return ;
+
+ case "rating":
+ return ;
+
+ case "autocomplete":
+ return ;
+
+ case "location":
+ return ;
+
+ case "checkbox":
+ default:
+ return ;
+ }
+}
+
+function FilterBodyRange({
+ minValue,
+ maxValue,
+ onRangeChange,
+}: FilterBodyProps) {
+ const [localMin, setLocalMin] = useState(minValue?.toString() ?? "");
+ const [localMax, setLocalMax] = useState(maxValue?.toString() ?? "");
+ const [rangeError, setRangeError] = useState(null);
+
+ // keep local inputs synced if parent passes new min/max
+ useEffect(() => {
+ setLocalMin(minValue?.toString() ?? "");
+ }, [minValue]);
+
+ useEffect(() => {
+ setLocalMax(maxValue?.toString() ?? "");
+ }, [maxValue]);
+
+ const handleRangeApply = () => {
+ if (!onRangeChange) return;
+
+ const min = localMin ? parseFloat(localMin) : NaN;
+ const max = localMax ? parseFloat(localMax) : NaN;
+
+ if (!isNaN(min) && !isNaN(max) && min >= max) {
+ setRangeError("Minimum must be less than maximum");
+ return;
+ }
+
+ const appliedMin = !isNaN(min) ? min : 0;
+ const appliedMax = !isNaN(max) ? max : 100;
+
+ setRangeError(null);
+ onRangeChange(appliedMin, appliedMax);
+ };
+
+ useEffect(() => {
+ const min = localMin ? parseFloat(localMin) : NaN;
+ const max = localMax ? parseFloat(localMax) : NaN;
+
+ if (!isNaN(min) && !isNaN(max)) {
+ setRangeError(min >= max ? "Minimum must be less than maximum" : null);
+ } else {
+ setRangeError(null);
+ }
+ }, [localMin, localMax]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $
+
+ setLocalMin(e.target.value)}
+ className={cn(
+ "h-9 border-cooper-gray-150 border-[1px] text-sm text-cooper-gray-400 pl-5",
+ rangeError ? "border-red-500" : "",
+ )}
+ onBlur={handleRangeApply}
+ onKeyDown={(e) => e.key === "Enter" && handleRangeApply()}
+ />
+
+
+
+
+
+
+ $
+
+ setLocalMax(e.target.value)}
+ className={cn(
+ "h-9 border-cooper-gray-150 border-[1px] text-sm text-cooper-gray-400 pl-5",
+ rangeError ? "border-red-500" : "",
+ )}
+ onBlur={handleRangeApply}
+ onKeyDown={(e) => e.key === "Enter" && handleRangeApply()}
+ />
+
+
+
+ {rangeError &&
{rangeError}
}
+
+ );
+}
+
+function FilterBodyRating({
+ selectedOptions,
+ onSelectionChange,
+}: FilterBodyProps) {
+ const minRating =
+ selectedOptions.length > 0 ? Math.min(...selectedOptions.map(Number)) : 0;
+ const maxRating =
+ selectedOptions.length > 0 ? Math.max(...selectedOptions.map(Number)) : 0;
+
+ const handleRatingClick = (rating: number) => {
+ if (!onSelectionChange) return;
+
+ if (selectedOptions.length === 0) {
+ onSelectionChange([rating.toString()]);
+ return;
+ }
+
+ if (selectedOptions.length === 1) {
+ const current = Number(selectedOptions[0]);
+ if (rating === current) {
+ onSelectionChange([]);
+ return;
+ }
+
+ const min = Math.min(current, rating);
+ const max = Math.max(current, rating);
+ const range: string[] = [];
+ for (let i = min; i <= max; i++) range.push(i.toString());
+ onSelectionChange(range);
+ return;
+ }
+
+ onSelectionChange([rating.toString()]);
+ };
+
+ return (
+
+ {[1, 2, 3, 4, 5].map((rating, index) => {
+ const isInRange =
+ rating >= minRating &&
+ rating <= maxRating &&
+ selectedOptions.length > 0;
+
+ return (
+
+ );
+ })}
+
+ );
+}
+
+function FilterBodyAutocomplete({
+ title,
+ options,
+ selectedOptions,
+ onSelectionChange,
+}: FilterBodyProps) {
+ return (
+
+
({
+ value: option.value ?? option.id,
+ label: option.label,
+ }))}
+ value={selectedOptions}
+ onChange={(selected) => onSelectionChange?.(selected)}
+ placeholder={`Search by ${title === "Industry" ? "industry" : "city or state"}`}
+ />
+
+ );
+}
+
+function FilterBodyLocation({
+ options,
+ selectedOptions,
+ onSelectionChange,
+ onSearchChange,
+}: FilterBodyProps) {
+ return (
+
+
({
+ value: option.id,
+ label: option.label,
+ }))}
+ value={selectedOptions}
+ onChange={(selected) => onSelectionChange?.(selected)}
+ placeholder="Search by city or state..."
+ onSearchChange={onSearchChange}
+ />
+
+ );
+}
+
+function FilterBodyCheckbox({
+ options,
+ selectedOptions,
+ onSelectionChange,
+}: FilterBodyProps) {
+ const [searchTerm, setSearchTerm] = useState("");
+
+ const filteredOptions = useMemo(() => {
+ const q = searchTerm.toLowerCase();
+ return options.filter((o) => o.label.toLowerCase().includes(q));
+ }, [options, searchTerm]);
+
+ const handleToggleOption = (optionId: string) => {
+ if (!onSelectionChange) return;
+ onSelectionChange(
+ selectedOptions.includes(optionId)
+ ? selectedOptions.filter((id) => id !== optionId)
+ : [...selectedOptions, optionId],
+ );
+ };
+
+ return (
+
+ {options.length > 5 && (
+
setSearchTerm(e.target.value)}
+ className="h-9"
+ />
+ )}
+
+
+ {filteredOptions.map((option) => (
+
+ handleToggleOption(option.id)}
+ />
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/src/app/_components/filters/role-type-selector.tsx b/apps/web/src/app/_components/filters/role-type-selector.tsx
new file mode 100644
index 00000000..c10b49ae
--- /dev/null
+++ b/apps/web/src/app/_components/filters/role-type-selector.tsx
@@ -0,0 +1,37 @@
+import { Chip } from "@cooper/ui/chip";
+
+interface RoleTypeSelectorProps {
+ onSelectedTypeChange: (t: "roles" | "companies" | "all") => void;
+ selectedType: "roles" | "companies" | "all";
+ data?: {
+ totalRolesCount: number;
+ totalCompanyCount: number;
+ };
+}
+
+// Component for selecting role type: All, Jobs, Companies
+export default function RoleTypeSelector({
+ onSelectedTypeChange,
+ selectedType,
+ data,
+}: RoleTypeSelectorProps) {
+ return (
+
+ onSelectedTypeChange("all")}
+ selected={selectedType === "all"}
+ />
+ onSelectedTypeChange("roles")}
+ label={`Jobs (${data?.totalRolesCount ?? "0"})`}
+ selected={selectedType === "roles"}
+ />
+ onSelectedTypeChange("companies")}
+ label={`Companies (${data?.totalCompanyCount ?? "0"})`}
+ selected={selectedType === "companies"}
+ />
+
+ );
+}
diff --git a/apps/web/src/app/_components/filters/sidebar-filter.tsx b/apps/web/src/app/_components/filters/sidebar-filter.tsx
new file mode 100644
index 00000000..93db0de1
--- /dev/null
+++ b/apps/web/src/app/_components/filters/sidebar-filter.tsx
@@ -0,0 +1,252 @@
+"use-client";
+
+import { useState, useEffect } from "react";
+import { api } from "~/trpc/react";
+import { industryOptions } from "../onboarding/constants";
+import { jobTypeOptions, workModelOptions } from "../onboarding/constants";
+import { abbreviatedStateName } from "~/utils/locationHelpers";
+import { Button } from "@cooper/ui/button";
+import RoleTypeSelector from "./role-type-selector";
+import SidebarSection from "./sidebar-section";
+import { ChevronRight } from "lucide-react";
+import { cn } from "@cooper/ui";
+
+interface FilterState {
+ industries: string[];
+ locations: string[];
+ jobTypes: string[];
+ hourlyPay: { min: number; max: number };
+ ratings: string[];
+ workModels?: string[];
+ overtimeWork?: string[];
+ companyCulture?: string[];
+}
+
+interface SidebarFilterProps {
+ isOpen: boolean;
+ onClose: () => void;
+ filters: FilterState;
+ onFilterChange: (filters: FilterState) => void;
+ selectedType: "roles" | "companies" | "all";
+ onSelectedTypeChange: (t: "roles" | "companies" | "all") => void;
+ data?: {
+ totalRolesCount: number;
+ totalCompanyCount: number;
+ };
+ isLoading?: boolean;
+}
+
+export default function SidebarFilter({
+ filters,
+ isOpen,
+ onFilterChange,
+ onClose,
+ selectedType,
+ onSelectedTypeChange,
+ data,
+ isLoading,
+}: SidebarFilterProps) {
+ const [searchTerm, setSearchTerm] = useState("");
+ const [prefix, setPrefix] = useState("");
+
+ useEffect(() => {
+ const newPrefix =
+ searchTerm.length === 3 ? searchTerm.slice(0, 3).toLowerCase() : null;
+ if (newPrefix && newPrefix !== prefix) {
+ setPrefix(newPrefix);
+ }
+ }, [prefix, searchTerm]);
+
+ const locationsToUpdate = api.location.getByPrefix.useQuery(
+ { prefix },
+ { enabled: searchTerm.length === 3 && prefix.length === 3 },
+ );
+
+ const handleFilterChange = (
+ key: keyof FilterState,
+ value: string[] | { min: number; max: number },
+ ) => {
+ const newFilters = {
+ ...filters,
+ [key]: value,
+ };
+ onFilterChange(newFilters);
+ };
+
+ // Industry options from your schema
+ const industryOptionsWithId = Object.entries(industryOptions).map(
+ ([_value, label]) => ({
+ id: label.value,
+ label: label.label,
+ value: label.value,
+ }),
+ );
+
+ // Location options
+ const locationOptions = locationsToUpdate.data
+ ? locationsToUpdate.data.map((loc) => ({
+ id: loc.id,
+ label: `${loc.city}${loc.state ? `, ${abbreviatedStateName(loc.state)}` : ""}`,
+ }))
+ : [];
+
+ // Job type options
+ const jobTypeOptionsWithId = jobTypeOptions.map((jobType) => ({
+ id: jobType.value,
+ label: jobType.label,
+ value: jobType.value,
+ }));
+
+ const workModelOptionsWithId = workModelOptions.map((workModel) => ({
+ // placeholder since not gonna implement backend yet
+ id: workModel.value,
+ label: workModel.label,
+ value: workModel.value,
+ }));
+
+ const clearAll = () => {
+ onFilterChange({
+ industries: [],
+ locations: [],
+ jobTypes: [],
+ hourlyPay: { min: 0, max: 0 },
+ ratings: [],
+ workModels: [],
+ overtimeWork: [],
+ companyCulture: [],
+ });
+ };
+
+ return (
+
+
e.stopPropagation()}
+ >
+
+
+
+
+
+
+ handleFilterChange("industries", selected)
+ }
+ />
+
+
+ handleFilterChange("locations", selected)
+ }
+ onSearchChange={(search) => setSearchTerm(search)}
+ />
+
+
+ handleFilterChange("jobTypes", selected)
+ }
+ />
+
+
+ {/* all of these don't work in the backend btw/dont rly have functionality atm. */}
+
On the job
+
+ handleFilterChange("workModels", selected)
+ }
+ />
+
+ handleFilterChange("overtimeWork", selected)
+ }
+ variant="subsection"
+ />
+
+ handleFilterChange("companyCulture", selected)
+ }
+ />
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/_components/filters/sidebar-section.tsx b/apps/web/src/app/_components/filters/sidebar-section.tsx
new file mode 100644
index 00000000..af5ab0c0
--- /dev/null
+++ b/apps/web/src/app/_components/filters/sidebar-section.tsx
@@ -0,0 +1,62 @@
+import { Button } from "@cooper/ui/button";
+import FilterBody from "./filter-body";
+import type { FilterOption, FilterVariant } from "./filter-body";
+
+import { cn } from "@cooper/ui";
+
+interface SidebarSectionProps {
+ title: string;
+ options: FilterOption[];
+ selectedOptions: string[];
+ onSelectionChange?: (selected: string[]) => void;
+ filterType?: FilterVariant;
+ onSearchChange?: (search: string) => void;
+ isLoadingOptions?: boolean;
+ variant?: "main" | "subsection";
+}
+
+export default function SidebarSection({
+ title,
+ options,
+ selectedOptions,
+ onSelectionChange,
+ filterType = "checkbox",
+ onSearchChange,
+ isLoadingOptions,
+ variant = "main",
+}: SidebarSectionProps) {
+ const handleClear = () => {
+ onSelectionChange?.([]);
+ };
+
+ return (
+
+
+
+ {title}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/_components/onboarding/constants/index.ts b/apps/web/src/app/_components/onboarding/constants/index.ts
index 64e41088..f65d915d 100644
--- a/apps/web/src/app/_components/onboarding/constants/index.ts
+++ b/apps/web/src/app/_components/onboarding/constants/index.ts
@@ -363,3 +363,14 @@ export const majors = [
"Theatre, BA",
"Theatre, BS",
];
+
+export const jobTypeOptions = [
+ { value: "CO-OP", label: "Co-op" },
+ { value: "INTERNSHIP", label: "Internship" },
+];
+
+export const workModelOptions = [
+ { value: "INPERSON", label: "In-person" },
+ { value: "REMOTE", label: "Remote" },
+ { value: "HYBRID", label: "Hybrid" },
+];
diff --git a/apps/web/src/app/_components/reviews/role-info.tsx b/apps/web/src/app/_components/reviews/role-info.tsx
index 44b95c9a..27b07968 100644
--- a/apps/web/src/app/_components/reviews/role-info.tsx
+++ b/apps/web/src/app/_components/reviews/role-info.tsx
@@ -25,6 +25,8 @@ import { ReviewCard } from "./review-card";
import ReviewSearchBar from "./review-search-bar";
import RoundBarGraph from "./round-bar-graph";
import type { ReviewType, RoleType } from "@cooper/db/schema";
+import { CompareControls } from "../compare/compare-ui";
+import { useCompare } from "../compare/compare-context";
interface RoleCardProps {
className?: string;
@@ -53,6 +55,8 @@ export function RoleInfo({ className, roleObj, onBack }: RoleCardProps) {
{ enabled: !!reviews.data?.[0]?.companyId },
);
+ const compare = useCompare();
+
// ===== ROLE DATA ===== //
const companyData = companyQuery.data;
const averages = api.role.getAverageById.useQuery({ roleId: roleObj.id });
@@ -153,7 +157,7 @@ export function RoleInfo({ className, roleObj, onBack }: RoleCardProps) {
/>
)}
-
+
{companyData ? (
@@ -189,29 +193,34 @@ export function RoleInfo({ className, roleObj, onBack }: RoleCardProps) {
-
- {reviews.isSuccess &&
- reviews.data.length > 0 &&
- (() => {
- return (
-
-
-
- {Math.round(
- Number(averages.data?.averageOverallRating) * 100,
- ) / 100}
+
+
+ {reviews.isSuccess &&
+ reviews.data.length > 0 &&
+ (() => {
+ return (
+
+
+
+ {Math.round(
+ Number(averages.data?.averageOverallRating) * 100,
+ ) / 100}
+
+ ({reviews.data.length} review
+ {reviews.data.length !== 1 && "s"})
- ({reviews.data.length} review
- {reviews.data.length !== 1 && "s"})
-
- );
- })()}
-
+ );
+ })()}
+
+ {!compare.isCompareMode && (
+
+ )}
+
diff --git a/packages/api/src/router/roleAndCompany.ts b/packages/api/src/router/roleAndCompany.ts
index dd9c4f4e..08be47ee 100644
--- a/packages/api/src/router/roleAndCompany.ts
+++ b/packages/api/src/router/roleAndCompany.ts
@@ -111,6 +111,8 @@ export const roleAndCompanyRouter = {
Array.isArray(filters.locations) && filters.locations.length > 0;
const ratingsFilterActive =
Array.isArray(filters.ratings) && filters.ratings.length > 0;
+ const jobTypeFilterActive =
+ Array.isArray(filters.jobTypes) && filters.jobTypes.length > 0;
// Build company -> location mapping if location filter is active
const companyLocationsMap = new Map();
@@ -217,6 +219,8 @@ export const roleAndCompanyRouter = {
const baseFilteredItems = combinedItems.filter((item) => {
const allowedIndustries = filters.industries ?? [];
const allowedLocations = filters.locations ?? [];
+ const allowedJobTypes = filters.jobTypes ?? [];
+
const industryOk = industryFilterActive
? item.type === "company"
? allowedIndustries.includes((item as CompanyType).industry)
@@ -241,6 +245,12 @@ export const roleAndCompanyRouter = {
})()
: true;
+ const jobTypeOk = jobTypeFilterActive
+ ? item.type === "role"
+ ? allowedJobTypes.includes((item as RoleType).jobType)
+ : true
+ : true;
+
// Pay range filter: use minPay(default 0) and maxPay(default Infinity)
const minPay = typeof filters.minPay === "number" ? filters.minPay : 0;
const maxPay =
@@ -271,7 +281,7 @@ export const roleAndCompanyRouter = {
return allowed.some((n) => avg >= n && avg <= n + 0.9);
})();
- return industryOk && locationOk && payOk && ratingOk;
+ return industryOk && locationOk && jobTypeOk && payOk && ratingOk;
});
const fuseOptions = ["title", "description", "companyName", "name"];
diff --git a/packages/ui/src/autocomplete.tsx b/packages/ui/src/autocomplete.tsx
index 6d9bcf23..32a612d1 100644
--- a/packages/ui/src/autocomplete.tsx
+++ b/packages/ui/src/autocomplete.tsx
@@ -71,7 +71,7 @@ export default function Autocomplete({
{