From dedc16467afef744d6e7f86d5c81565174f06c37 Mon Sep 17 00:00:00 2001 From: Michael Song Date: Sun, 28 Dec 2025 18:33:01 -0500 Subject: [PATCH 1/8] Changed Compare mode button placement to match figma --- .../app/(pages)/(dashboard)/(roles)/page.tsx | 5 -- .../src/app/_components/reviews/role-info.tsx | 55 +++++++++++-------- 2 files changed, 32 insertions(+), 28 deletions(-) diff --git a/apps/web/src/app/(pages)/(dashboard)/(roles)/page.tsx b/apps/web/src/app/(pages)/(dashboard)/(roles)/page.tsx index f09c2274..be07ab8c 100644 --- a/apps/web/src/app/(pages)/(dashboard)/(roles)/page.tsx +++ b/apps/web/src/app/(pages)/(dashboard)/(roles)/page.tsx @@ -653,11 +653,6 @@ export default function Roles() { !showRoleInfo && "hidden md:block", // Hide on mobile if RoleCardPreview is visible )} > - {selectedRole && !compare.isCompareMode && ( -
- -
- )} {selectedRole ? ( compare.isCompareMode ? ( 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 && ( + + )} +
From aea5855b3ce1e204611e6431d809041308292954 Mon Sep 17 00:00:00 2001 From: Michael Song Date: Sun, 28 Dec 2025 19:38:53 -0500 Subject: [PATCH 2/8] job type functionality --- packages/api/src/router/roleAndCompany.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/api/src/router/roleAndCompany.ts b/packages/api/src/router/roleAndCompany.ts index dd9c4f4e..8da481bf 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,18 @@ export const roleAndCompanyRouter = { })() : true; + const jobTypeMap: Record = { + "CO-OP": "Co-op", + INTERNSHIP: "Internship", + }; + const jobTypeOk = jobTypeFilterActive + ? item.type === "role" + ? allowedJobTypes.includes( + jobTypeMap[(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 +287,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"]; From a7b4856dfac3f25d8d30e8b3de25ab5ea67af768 Mon Sep 17 00:00:00 2001 From: Michael Song Date: Mon, 29 Dec 2025 19:20:23 -0500 Subject: [PATCH 3/8] started filter + refactored dropdown filter (refactoring was a bit vibecoded but im 90% sure it works) --- apps/web/public/svg/sidebarFilter.svg | 3 + .../app/(pages)/(dashboard)/(roles)/page.tsx | 62 +++- .../_components/filters/dropdown-filter.tsx | 346 ++---------------- .../filters/dropdown-filters-bar.tsx | 11 +- .../app/_components/filters/filter-body.tsx | 331 +++++++++++++++++ .../filters/role-type-selector.tsx | 37 ++ .../_components/filters/sidebar-filter.tsx | 142 +++++++ .../_components/onboarding/constants/index.ts | 5 + 8 files changed, 603 insertions(+), 334 deletions(-) create mode 100644 apps/web/public/svg/sidebarFilter.svg create mode 100644 apps/web/src/app/_components/filters/filter-body.tsx create mode 100644 apps/web/src/app/_components/filters/role-type-selector.tsx create mode 100644 apps/web/src/app/_components/filters/sidebar-filter.tsx 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 be07ab8c..71f2145f 100644 --- a/apps/web/src/app/(pages)/(dashboard)/(roles)/page.tsx +++ b/apps/web/src/app/(pages)/(dashboard)/(roles)/page.tsx @@ -2,6 +2,7 @@ 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"; @@ -28,7 +29,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[]; @@ -56,6 +59,8 @@ export default function Roles() { const router = useRouter(); const compare = useCompare(); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const [selectedType, setSelectedType] = useState< "roles" | "companies" | "all" >("all"); @@ -433,8 +438,23 @@ export default function Roles() {
-
- +
+ +
{compare.isCompareMode && selectedRole && (
@@ -494,23 +514,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") { @@ -673,6 +684,19 @@ 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, + }} + /> ); } diff --git a/apps/web/src/app/_components/filters/dropdown-filter.tsx b/apps/web/src/app/_components/filters/dropdown-filter.tsx index aac2738d..963c5932 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 { useEffect, 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,11 @@ 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, { + type FilterOption, + type FilterVariant, +} from "~/app/_components/filters/filter-body"; interface DropdownFilterProps { title: string; @@ -30,18 +25,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 +45,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 +93,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 +132,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..22117d45 100644 --- a/apps/web/src/app/_components/filters/dropdown-filters-bar.tsx +++ b/apps/web/src/app/_components/filters/dropdown-filters-bar.tsx @@ -16,22 +16,16 @@ 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,7 +50,6 @@ export default function DropdownFiltersBar({ ...filters, [key]: value, }; - setFilters(newFilters); onFilterChange(newFilters); }; 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..ed0cfac8 --- /dev/null +++ b/apps/web/src/app/_components/filters/filter-body.tsx @@ -0,0 +1,331 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Image from "next/image"; + +import { cn } from "@cooper/ui"; +import { Button } from "@cooper/ui/button"; +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.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..3cdbe389 --- /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..2e7886af --- /dev/null +++ b/apps/web/src/app/_components/filters/sidebar-filter.tsx @@ -0,0 +1,142 @@ +"use-client"; + +import { useState, useEffect } from "react"; +import { api } from "~/trpc/react"; +import DropdownFilter from "./dropdown-filter"; +import { industryOptions } from "../onboarding/constants"; +import { jobTypeOptions } from "../onboarding/constants"; +import { abbreviatedStateName } from "~/utils/locationHelpers"; +import { Button } from "@cooper/ui/button"; +import { Chip } from "@cooper/ui/chip"; +import Autocomplete from "@cooper/ui/autocomplete"; +import RoleTypeSelector from "./role-type-selector"; + +interface FilterState { + industries: string[]; + locations: string[]; + jobTypes: string[]; + hourlyPay: { min: number; max: number }; + ratings: 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; + }; +} + +export default function SidebarFilter({ + filters, + isOpen, + onFilterChange, + onClose, + selectedType, + onSelectedTypeChange, + data, +}: SidebarFilterProps) { + if (!isOpen) { + return null; + } + + 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, + })); + + return ( +
+
+
+
+ + +

Filters

+
+
+ +
+ + + handleFilterChange("industries", selected) + } + isSideBar + /> +
+
+
+ ); +} diff --git a/apps/web/src/app/_components/onboarding/constants/index.ts b/apps/web/src/app/_components/onboarding/constants/index.ts index 64e41088..fac63066 100644 --- a/apps/web/src/app/_components/onboarding/constants/index.ts +++ b/apps/web/src/app/_components/onboarding/constants/index.ts @@ -363,3 +363,8 @@ export const majors = [ "Theatre, BA", "Theatre, BS", ]; + +export const jobTypeOptions = [ + { value: "CO-OP", label: "Co-op" }, + { value: "INTERNSHIP", label: "Internship" }, +]; From 0fc9f20ad7006ea27ceda4cb6688920cd1d8c22a Mon Sep 17 00:00:00 2001 From: Michael Song Date: Mon, 29 Dec 2025 22:13:41 -0500 Subject: [PATCH 4/8] sidebar stuff --- .../app/(pages)/(dashboard)/(roles)/page.tsx | 7 + .../_components/filters/dropdown-filter.tsx | 2 +- .../filters/dropdown-filters-bar.tsx | 17 ++- .../app/_components/filters/filter-body.tsx | 2 - .../_components/filters/sidebar-filter.tsx | 123 ++++++++++++++++-- .../_components/filters/sidebar-section.tsx | 63 +++++++++ .../_components/onboarding/constants/index.ts | 6 + packages/ui/src/autocomplete.tsx | 2 +- 8 files changed, 196 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/app/_components/filters/sidebar-section.tsx diff --git a/apps/web/src/app/(pages)/(dashboard)/(roles)/page.tsx b/apps/web/src/app/(pages)/(dashboard)/(roles)/page.tsx index 71f2145f..8ab6a2a8 100644 --- a/apps/web/src/app/(pages)/(dashboard)/(roles)/page.tsx +++ b/apps/web/src/app/(pages)/(dashboard)/(roles)/page.tsx @@ -39,6 +39,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) @@ -75,6 +78,9 @@ export default function Roles() { jobTypes: [], hourlyPay: { min: 0, max: 0 }, ratings: [], + workModels: [], + overtimeWork: [], + companyCulture: [], }); const rolesAndCompaniesPerPage = 10; @@ -696,6 +702,7 @@ export default function Roles() { 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 963c5932..f2c61628 100644 --- a/apps/web/src/app/_components/filters/dropdown-filter.tsx +++ b/apps/web/src/app/_components/filters/dropdown-filter.tsx @@ -17,7 +17,7 @@ import { Button } from "@cooper/ui/button"; import FilterBody, { type FilterOption, type FilterVariant, -} from "~/app/_components/filters/filter-body"; +} from "./filter-body"; interface DropdownFilterProps { title: string; 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 22117d45..c02bca6a 100644 --- a/apps/web/src/app/_components/filters/dropdown-filters-bar.tsx +++ b/apps/web/src/app/_components/filters/dropdown-filters-bar.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } 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"; @@ -70,13 +70,12 @@ export default function DropdownFiltersBar({ })) : []; - // 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 (
@@ -104,7 +103,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 index ed0cfac8..7a270c5d 100644 --- a/apps/web/src/app/_components/filters/filter-body.tsx +++ b/apps/web/src/app/_components/filters/filter-body.tsx @@ -28,12 +28,10 @@ interface FilterBodyProps { 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; } diff --git a/apps/web/src/app/_components/filters/sidebar-filter.tsx b/apps/web/src/app/_components/filters/sidebar-filter.tsx index 2e7886af..82904c33 100644 --- a/apps/web/src/app/_components/filters/sidebar-filter.tsx +++ b/apps/web/src/app/_components/filters/sidebar-filter.tsx @@ -2,14 +2,12 @@ import { useState, useEffect } from "react"; import { api } from "~/trpc/react"; -import DropdownFilter from "./dropdown-filter"; import { industryOptions } from "../onboarding/constants"; -import { jobTypeOptions } from "../onboarding/constants"; +import { jobTypeOptions, workModelOptions } from "../onboarding/constants"; import { abbreviatedStateName } from "~/utils/locationHelpers"; import { Button } from "@cooper/ui/button"; -import { Chip } from "@cooper/ui/chip"; -import Autocomplete from "@cooper/ui/autocomplete"; import RoleTypeSelector from "./role-type-selector"; +import SidebarSection from "./sidebar-section"; interface FilterState { industries: string[]; @@ -17,22 +15,23 @@ interface FilterState { 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({ @@ -43,6 +42,7 @@ export default function SidebarFilter({ selectedType, onSelectedTypeChange, data, + isLoading, }: SidebarFilterProps) { if (!isOpen) { return null; @@ -99,10 +99,30 @@ export default function SidebarFilter({ 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 (
-
+
- -

Filters

+

Filters

- - handleFilterChange("industries", selected) } - isSideBar /> +
+ + 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..13200541 --- /dev/null +++ b/apps/web/src/app/_components/filters/sidebar-section.tsx @@ -0,0 +1,63 @@ +import { Button } from "@cooper/ui/button"; +import FilterBody, { + type FilterOption, + type 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 fac63066..f65d915d 100644 --- a/apps/web/src/app/_components/onboarding/constants/index.ts +++ b/apps/web/src/app/_components/onboarding/constants/index.ts @@ -368,3 +368,9 @@ 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/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({ { From ebb060f2cbf1a8d9550c0d5666923176c3dddab8 Mon Sep 17 00:00:00 2001 From: Michael Song Date: Mon, 29 Dec 2025 23:08:26 -0500 Subject: [PATCH 5/8] rough draft --- .../app/(pages)/(dashboard)/(roles)/page.tsx | 2 +- .../filters/dropdown-filters-bar.tsx | 41 ++++++++++++++++--- .../app/_components/filters/filter-body.tsx | 2 +- .../_components/filters/sidebar-filter.tsx | 37 +++++++++++------ 4 files changed, 62 insertions(+), 20 deletions(-) diff --git a/apps/web/src/app/(pages)/(dashboard)/(roles)/page.tsx b/apps/web/src/app/(pages)/(dashboard)/(roles)/page.tsx index 8ab6a2a8..e757070e 100644 --- a/apps/web/src/app/(pages)/(dashboard)/(roles)/page.tsx +++ b/apps/web/src/app/(pages)/(dashboard)/(roles)/page.tsx @@ -444,7 +444,7 @@ export default function Roles() {
-
+
+ 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]) => ({ @@ -62,13 +80,24 @@ 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 const jobTypeOptionsWithId = jobTypeOptions.map((jobType) => ({ diff --git a/apps/web/src/app/_components/filters/filter-body.tsx b/apps/web/src/app/_components/filters/filter-body.tsx index 7a270c5d..dff06717 100644 --- a/apps/web/src/app/_components/filters/filter-body.tsx +++ b/apps/web/src/app/_components/filters/filter-body.tsx @@ -242,7 +242,7 @@ function FilterBodyAutocomplete({
({ - value: option.id, + value: option.value || option.id, label: option.label, }))} value={selectedOptions} diff --git a/apps/web/src/app/_components/filters/sidebar-filter.tsx b/apps/web/src/app/_components/filters/sidebar-filter.tsx index 82904c33..282ab67f 100644 --- a/apps/web/src/app/_components/filters/sidebar-filter.tsx +++ b/apps/web/src/app/_components/filters/sidebar-filter.tsx @@ -8,6 +8,8 @@ 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[]; @@ -44,10 +46,6 @@ export default function SidebarFilter({ data, isLoading, }: SidebarFilterProps) { - if (!isOpen) { - return null; - } - const [searchTerm, setSearchTerm] = useState(""); const [prefix, setPrefix] = useState(""); @@ -120,16 +118,31 @@ export default function SidebarFilter({ }; return ( -
-
-
-
+
+
e.stopPropagation()} + > +
+

Filters

@@ -222,7 +235,7 @@ export default function SidebarFilter({ Clear all
diff --git a/apps/web/src/app/_components/filters/sidebar-section.tsx b/apps/web/src/app/_components/filters/sidebar-section.tsx index 13200541..af5ab0c0 100644 --- a/apps/web/src/app/_components/filters/sidebar-section.tsx +++ b/apps/web/src/app/_components/filters/sidebar-section.tsx @@ -1,8 +1,7 @@ import { Button } from "@cooper/ui/button"; -import FilterBody, { - type FilterOption, - type FilterVariant, -} from "./filter-body"; +import FilterBody from "./filter-body"; +import type { FilterOption, FilterVariant } from "./filter-body"; + import { cn } from "@cooper/ui"; interface SidebarSectionProps { diff --git a/packages/api/src/router/roleAndCompany.ts b/packages/api/src/router/roleAndCompany.ts index 8da481bf..1319657b 100644 --- a/packages/api/src/router/roleAndCompany.ts +++ b/packages/api/src/router/roleAndCompany.ts @@ -252,7 +252,7 @@ export const roleAndCompanyRouter = { const jobTypeOk = jobTypeFilterActive ? item.type === "role" ? allowedJobTypes.includes( - jobTypeMap[(item as RoleType).jobType] || "", + jobTypeMap[(item as RoleType).jobType] ?? "", ) : true : true; From 8de7272d02da4ab910c60959bcbe19ea327af615 Mon Sep 17 00:00:00 2001 From: Michael Song Date: Wed, 31 Dec 2025 14:43:03 -0500 Subject: [PATCH 7/8] fixed job type filter --- packages/api/src/router/roleAndCompany.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/api/src/router/roleAndCompany.ts b/packages/api/src/router/roleAndCompany.ts index 1319657b..24db3d6a 100644 --- a/packages/api/src/router/roleAndCompany.ts +++ b/packages/api/src/router/roleAndCompany.ts @@ -245,15 +245,9 @@ export const roleAndCompanyRouter = { })() : true; - const jobTypeMap: Record = { - "CO-OP": "Co-op", - INTERNSHIP: "Internship", - }; const jobTypeOk = jobTypeFilterActive ? item.type === "role" - ? allowedJobTypes.includes( - jobTypeMap[(item as RoleType).jobType] ?? "", - ) + ? allowedJobTypes.includes((item as RoleType).jobType ?? "") : true : true; From c56cbf2c5c386f330c94c6c5cbaa03db61f33701 Mon Sep 17 00:00:00 2001 From: Michael Song Date: Wed, 31 Dec 2025 14:45:53 -0500 Subject: [PATCH 8/8] linter --- packages/api/src/router/roleAndCompany.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/src/router/roleAndCompany.ts b/packages/api/src/router/roleAndCompany.ts index 24db3d6a..08be47ee 100644 --- a/packages/api/src/router/roleAndCompany.ts +++ b/packages/api/src/router/roleAndCompany.ts @@ -247,7 +247,7 @@ export const roleAndCompanyRouter = { const jobTypeOk = jobTypeFilterActive ? item.type === "role" - ? allowedJobTypes.includes((item as RoleType).jobType ?? "") + ? allowedJobTypes.includes((item as RoleType).jobType) : true : true;