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{" "} - Star icon -
- ); - } + if (minRating === maxRating) return `${minRating}.0+ stars`; + + return ( +
+ {minRating}.0 - {maxRating}.0{" "} + Star icon +
+ ); } 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()} + > +
+
+ +

Filters

+
+
+ +
+ + 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 ( -
- Star icon -
- {Math.round( - Number(averages.data?.averageOverallRating) * 100, - ) / 100} +
+ + {reviews.isSuccess && + reviews.data.length > 0 && + (() => { + return ( +
+ Star icon +
+ {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({ {