diff --git a/frontend/src/__tests__/ArmorMembers.test.js b/frontend/src/__tests__/ArmorMembers.test.js
new file mode 100644
index 0000000..d582cb6
--- /dev/null
+++ b/frontend/src/__tests__/ArmorMembers.test.js
@@ -0,0 +1,264 @@
+import React from "react";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { MemoryRouter, useLocation } from "react-router-dom";
+import ArmorMembers from "../landing_page/landing_page_armor/ArmorMembers";
+
+const HEADER = ["First Name", "Last Name", "Company Name", "Job Title", "LinkedIn"];
+
+// 25 members so the dataset spans 3 pages at pageSize=10. Three "Anote" members
+// are deliberately placed on page 1 (index 0), page 2 (index 12), and page 3
+// (index 24) so that filtering-across-the-full-dataset bugs are caught.
+function buildRows() {
+ const rows = [];
+ for (let i = 0; i < 25; i++) {
+ if (i === 0) {
+ rows.push([`Member${i}`, `Test${i}`, "Anote", "Founder / CEO", ""]);
+ } else if (i === 12) {
+ rows.push([`Member${i}`, `Test${i}`, "Anote", "Data Scientist", ""]);
+ } else if (i === 24) {
+ rows.push([`Member${i}`, `Test${i}`, "Anote", "Founder & CEO", ""]);
+ } else {
+ rows.push([`Member${i}`, `Test${i}`, "Other Co", "Engineer", ""]);
+ }
+ }
+ return rows;
+}
+
+const SHEET_VALUES = [HEADER, ...buildRows()];
+
+function LocationProbe() {
+ const location = useLocation();
+ return
{location.search}
;
+}
+
+function renderMembers(initialEntries = ["/community/members"]) {
+ return render(
+
+
+
+
+ );
+}
+
+async function waitForLoaded() {
+ await screen.findByRole("heading", { name: "Community Members" });
+}
+
+function getCompanyCombobox() {
+ return screen.getByRole("combobox", { name: "Filter by company" });
+}
+
+function getJobTitleCombobox() {
+ return screen.getByRole("combobox", { name: "Filter by job title" });
+}
+
+async function selectCompanyOption(text) {
+ const input = getCompanyCombobox();
+ fireEvent.change(input, { target: { value: text } });
+ const option = await screen.findByRole("option", { name: text });
+ fireEvent.mouseDown(option);
+}
+
+function typeJobTitleAndCommit(text) {
+ const input = getJobTitleCombobox();
+ fireEvent.change(input, { target: { value: text } });
+ fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
+}
+
+beforeEach(() => {
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ values: SHEET_VALUES }),
+ });
+});
+
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
+test("shows a loading state while the sheet is being fetched", () => {
+ global.fetch.mockReturnValue(new Promise(() => {}));
+ renderMembers();
+ expect(screen.getByText("Loading...")).toBeInTheDocument();
+});
+
+test("shows an error state when the sheet has no data", async () => {
+ global.fetch.mockResolvedValue({ ok: true, json: async () => ({}) });
+ renderMembers();
+ await screen.findByText(/Error: No data found in sheet/i);
+});
+
+test("shows an error state when the fetch itself fails", async () => {
+ global.fetch.mockRejectedValue(new Error("Network down"));
+ renderMembers();
+ await screen.findByText(/Error: Network down/i);
+});
+
+test("filters by Company across the full dataset, not just the current page", async () => {
+ renderMembers();
+ await waitForLoaded();
+
+ await selectCompanyOption("Anote");
+
+ expect(await screen.findByText("Showing 3 members")).toBeInTheDocument();
+ // Member12 and Member24 would be on page 2/3 under naive current-page filtering.
+ expect(screen.getByText("Member0")).toBeInTheDocument();
+ expect(screen.getByText("Member12")).toBeInTheDocument();
+ expect(screen.getByText("Member24")).toBeInTheDocument();
+ expect(screen.queryByText("Member1")).not.toBeInTheDocument();
+});
+
+test("filters by Job Title using keyword/contains matching for CEO variants", async () => {
+ renderMembers();
+ await waitForLoaded();
+
+ typeJobTitleAndCommit("CEO");
+
+ await screen.findByText("Showing 2 members");
+ expect(screen.getByText("Member0")).toBeInTheDocument(); // Founder / CEO
+ expect(screen.getByText("Member24")).toBeInTheDocument(); // Founder & CEO
+ expect(screen.queryByText("Member12")).not.toBeInTheDocument(); // Data Scientist
+});
+
+test("matches Job Title case-insensitively", async () => {
+ renderMembers();
+ await waitForLoaded();
+
+ typeJobTitleAndCommit("ceo");
+
+ await screen.findByText("Showing 2 members");
+});
+
+test("combines Company and Job Title filters with AND semantics", async () => {
+ renderMembers();
+ await waitForLoaded();
+
+ await selectCompanyOption("Anote");
+ typeJobTitleAndCommit("CEO");
+
+ await screen.findByText("Showing 2 members");
+ expect(screen.getByText("Member0")).toBeInTheDocument();
+ expect(screen.getByText("Member24")).toBeInTheDocument();
+ expect(screen.queryByText("Member12")).not.toBeInTheDocument();
+});
+
+test("combines Company + Job Title with the general search box", async () => {
+ renderMembers();
+ await waitForLoaded();
+
+ await selectCompanyOption("Anote");
+ fireEvent.change(screen.getByLabelText("Search members"), {
+ target: { value: "Member24" },
+ });
+
+ await screen.findByText("Showing 1 member", {}, { timeout: 2000 });
+ expect(screen.getByText("Member24")).toBeInTheDocument();
+});
+
+test("resets pagination to page 1 when a filter changes", async () => {
+ renderMembers(["/community/members?page=2"]);
+ await waitForLoaded();
+ expect(screen.getByLabelText("Current page")).toHaveValue(2);
+
+ typeJobTitleAndCommit("Engineer");
+
+ await waitFor(() => expect(screen.getByLabelText("Current page")).toHaveValue(1));
+});
+
+test("preserves active filters while navigating between pages", async () => {
+ renderMembers();
+ await waitForLoaded();
+
+ typeJobTitleAndCommit("Engineer"); // 22 matches -> 3 pages
+ await screen.findByText("Showing 22 members");
+
+ fireEvent.click(screen.getByLabelText("Next page"));
+
+ await waitFor(() => expect(screen.getByLabelText("Current page")).toHaveValue(2));
+ expect(
+ screen.getByRole("button", { name: "Remove Job Title filter: Engineer" })
+ ).toBeInTheDocument();
+});
+
+test("clears all filters via the Clear filters action", async () => {
+ renderMembers();
+ await waitForLoaded();
+
+ await selectCompanyOption("Anote");
+ typeJobTitleAndCommit("CEO");
+ await screen.findByText("Showing 2 members");
+
+ fireEvent.click(screen.getByRole("button", { name: "Clear all filters" }));
+
+ await screen.findByText("Showing 25 members");
+ expect(screen.queryByRole("button", { name: /Remove Company filter/ })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /Remove Job Title filter/ })).not.toBeInTheDocument();
+});
+
+test("shows an empty state with a clear-filters action when nothing matches", async () => {
+ renderMembers();
+ await waitForLoaded();
+
+ await selectCompanyOption("Other Co");
+ typeJobTitleAndCommit("CEO"); // Other Co members are all "Engineer"
+
+ await screen.findByText("No members match the selected filters.");
+ fireEvent.click(screen.getByRole("button", { name: "Clear filters" }));
+
+ await screen.findByText("Showing 25 members");
+});
+
+test("removing an individual filter chip clears only that filter", async () => {
+ renderMembers();
+ await waitForLoaded();
+
+ await selectCompanyOption("Anote");
+ typeJobTitleAndCommit("CEO");
+ await screen.findByText("Showing 2 members");
+
+ fireEvent.click(screen.getByLabelText("Remove Job Title filter: CEO"));
+
+ await screen.findByText("Showing 3 members");
+ expect(screen.getByRole("button", { name: "Remove Company filter: Anote" })).toBeInTheDocument();
+});
+
+test("Company dropdown options are deduped, blank-free, and case/whitespace-insensitive", async () => {
+ renderMembers();
+ await waitForLoaded();
+
+ fireEvent.focus(getCompanyCombobox());
+ const options = await screen.findAllByRole("option");
+ const optionTexts = options.map((o) => o.textContent);
+
+ expect(optionTexts).toEqual(["Anote", "Other Co"]);
+ expect(optionTexts).not.toContain("");
+});
+
+test("hydrates search/filter/page state from the URL on initial load", async () => {
+ renderMembers(["/community/members?company=Anote&jobTitle=CEO"]);
+ await waitForLoaded();
+
+ await screen.findByText("Showing 2 members");
+ expect(screen.getByRole("button", { name: "Remove Company filter: Anote" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Remove Job Title filter: CEO" })).toBeInTheDocument();
+});
+
+test("syncs active filters to the URL and omits empty parameters", async () => {
+ renderMembers();
+ await waitForLoaded();
+
+ await selectCompanyOption("Anote");
+
+ await waitFor(() => {
+ expect(screen.getByTestId("location-search").textContent).toContain("company=Anote");
+ });
+ const search = screen.getByTestId("location-search").textContent;
+ expect(search).not.toContain("jobTitle=");
+ expect(search).not.toContain("page=");
+
+ fireEvent.click(screen.getByRole("button", { name: "Clear all filters" }));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("location-search").textContent).toBe("");
+ });
+});
diff --git a/frontend/src/__tests__/memberFilters.test.js b/frontend/src/__tests__/memberFilters.test.js
new file mode 100644
index 0000000..fb749c8
--- /dev/null
+++ b/frontend/src/__tests__/memberFilters.test.js
@@ -0,0 +1,121 @@
+import {
+ normalizeValue,
+ normalizeForCompare,
+ getUniqueOptions,
+ matchesGeneralSearch,
+ matchesCompanyFilter,
+ matchesJobTitleFilter,
+ applyMemberFilters,
+} from "../landing_page/landing_page_armor/memberFilters";
+
+const members = [
+ { firstName: "Alice", lastName: "Chen", company: "Anote", title: "Founder / CEO" },
+ { firstName: "Bob", lastName: "Diaz", company: " anote ", title: "Data Scientist" },
+ { firstName: "Carol", lastName: "Evans", company: "OpenAI", title: "Research Engineer" },
+ { firstName: "Dave", lastName: "Frank", company: "", title: "" },
+ { firstName: "Erin", lastName: "Gomez", company: "OpenAI", title: "Founder & CEO" },
+];
+
+describe("normalizeValue / normalizeForCompare", () => {
+ test("trims whitespace and preserves original casing for normalizeValue", () => {
+ expect(normalizeValue(" Anote ")).toBe("Anote");
+ expect(normalizeValue(null)).toBe("");
+ expect(normalizeValue(undefined)).toBe("");
+ });
+
+ test("normalizeForCompare lowercases and trims", () => {
+ expect(normalizeForCompare(" Anote ")).toBe("anote");
+ });
+});
+
+describe("getUniqueOptions", () => {
+ test("dedupes case/whitespace-insensitively, keeping first-seen casing", () => {
+ const options = getUniqueOptions(members, "company");
+ expect(options).toEqual(["Anote", "OpenAI"]);
+ });
+
+ test("excludes blank/null values", () => {
+ const options = getUniqueOptions(members, "title");
+ expect(options).not.toContain("");
+ // Founder / CEO, Data Scientist, Research Engineer, Founder & CEO
+ expect(options.length).toBe(4);
+ });
+
+ test("returns options sorted case-insensitively", () => {
+ const options = getUniqueOptions(
+ [{ company: "zeta" }, { company: "Alpha" }, { company: "beta" }],
+ "company"
+ );
+ expect(options).toEqual(["Alpha", "beta", "zeta"]);
+ });
+});
+
+describe("matchesGeneralSearch", () => {
+ test("matches across name, company, and title", () => {
+ expect(matchesGeneralSearch(members[0], "anote")).toBe(true);
+ expect(matchesGeneralSearch(members[0], "chen")).toBe(true);
+ expect(matchesGeneralSearch(members[0], "ceo")).toBe(true);
+ expect(matchesGeneralSearch(members[0], "nomatch")).toBe(false);
+ });
+
+ test("empty query matches everything", () => {
+ expect(matchesGeneralSearch(members[0], "")).toBe(true);
+ });
+});
+
+describe("matchesCompanyFilter", () => {
+ test("matches case-insensitively and trims whitespace", () => {
+ expect(matchesCompanyFilter(members[0], "anote")).toBe(true);
+ expect(matchesCompanyFilter(members[0], "ANOTE")).toBe(true);
+ expect(matchesCompanyFilter(members[1], "Anote")).toBe(true); // " anote " stored value
+ });
+
+ test("does not fuzzy-match distinct company names", () => {
+ expect(matchesCompanyFilter(members[2], "Open")).toBe(false); // "OpenAI" != "Open"
+ });
+
+ test("empty filter matches everything", () => {
+ expect(matchesCompanyFilter(members[0], "")).toBe(true);
+ });
+});
+
+describe("matchesJobTitleFilter", () => {
+ test("keyword/contains matching for CEO variants", () => {
+ expect(matchesJobTitleFilter(members[0], "CEO")).toBe(true); // Founder / CEO
+ expect(matchesJobTitleFilter(members[4], "ceo")).toBe(true); // Founder & CEO
+ expect(matchesJobTitleFilter(members[1], "CEO")).toBe(false); // Data Scientist
+ });
+
+ test("empty filter matches everything", () => {
+ expect(matchesJobTitleFilter(members[0], "")).toBe(true);
+ });
+});
+
+describe("applyMemberFilters", () => {
+ test("combines search, company, and job title with AND semantics", () => {
+ const result = applyMemberFilters(members, {
+ query: "",
+ company: "OpenAI",
+ jobTitle: "CEO",
+ });
+ expect(result.map((m) => m.firstName)).toEqual(["Erin"]);
+ });
+
+ test("combines general search with company filter", () => {
+ const result = applyMemberFilters(members, {
+ query: "data",
+ company: "Anote",
+ jobTitle: "",
+ });
+ expect(result.map((m) => m.firstName)).toEqual(["Bob"]);
+ });
+
+ test("returns empty array when nothing matches", () => {
+ const result = applyMemberFilters(members, {
+ query: "",
+ company: "Nonexistent Co",
+ jobTitle: "",
+ });
+ expect(result).toEqual([]);
+ });
+});
diff --git a/frontend/src/landing_page/landing_page_armor/ArmorMembers.js b/frontend/src/landing_page/landing_page_armor/ArmorMembers.js
index 65da91d..d731afe 100644
--- a/frontend/src/landing_page/landing_page_armor/ArmorMembers.js
+++ b/frontend/src/landing_page/landing_page_armor/ArmorMembers.js
@@ -1,25 +1,46 @@
-import React, { useEffect, useState } from "react";
+import React, { useEffect, useMemo, useRef, useState } from "react";
+import { useSearchParams } from "react-router-dom";
import classNames from "classnames";
import SEO from "../../util/SEO";
import ShareButton from "./ShareButton";
+import FilterCombobox from "./FilterCombobox";
+import useDebouncedValue from "../../util/useDebouncedValue";
+import {
+ applyMemberFilters,
+ getUniqueOptions,
+} from "./memberFilters";
const SHEET_ID = "1IREd2vCxo7rDGOoDUYtzoywVGLqqUFCsu3qGsA0HsMc";
const API_KEY = "AIzaSyCjxXVDGAolugKgrTXpJ0HmAjL0lLxLN1E";
const SHEET_NAME = "Sheet-1";
+function parsePageParam(searchParams) {
+ const raw = parseInt(searchParams.get("page"), 10);
+ return Number.isFinite(raw) && raw >= 1 ? raw : 1;
+}
+
function ArmorMembers() {
const [members, setMembers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
- const [query, setQuery] = useState("");
- const [page, setPage] = useState(1);
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const [rawQuery, setRawQuery] = useState(() => searchParams.get("search") || "");
+ const [company, setCompany] = useState(() => searchParams.get("company") || "");
+ const [jobTitle, setJobTitle] = useState(() => searchParams.get("jobTitle") || "");
+ const [page, setPage] = useState(() => parsePageParam(searchParams));
const pageSize = 10;
+ const debouncedQuery = useDebouncedValue(rawQuery, 250);
+ const isFirstFilterRun = useRef(true);
+
useEffect(() => {
+ const controller = new AbortController();
+
const fetchData = async () => {
try {
const url = `https://sheets.googleapis.com/v4/spreadsheets/${SHEET_ID}/values/${encodeURIComponent(SHEET_NAME)}?key=${API_KEY}`;
- const res = await fetch(url);
+ const res = await fetch(url, { signal: controller.signal });
const data = await res.json();
if (!data.values || data.values.length < 2) {
@@ -45,29 +66,68 @@ function ArmorMembers() {
setMembers(parsed);
} catch (err) {
- setError(err.message);
+ if (err.name !== "AbortError") {
+ setError(err.message);
+ }
} finally {
setLoading(false);
}
};
fetchData();
+
+ return () => controller.abort();
}, []);
- const filtered = members.filter((m) => {
- const q = query.toLowerCase();
- return (
- m.firstName.toLowerCase().includes(q) ||
- m.lastName.toLowerCase().includes(q) ||
- m.company.toLowerCase().includes(q) ||
- m.title.toLowerCase().includes(q)
- );
- });
+ const companyOptions = useMemo(() => getUniqueOptions(members, "company"), [members]);
+ const jobTitleOptions = useMemo(() => getUniqueOptions(members, "title"), [members]);
+
+ const filtered = useMemo(
+ () => applyMemberFilters(members, { query: debouncedQuery, company, jobTitle }),
+ [members, debouncedQuery, company, jobTitle]
+ );
const pages = Math.ceil(filtered.length / pageSize);
const slice = filtered.slice((page - 1) * pageSize, page * pageSize);
- if (loading) return Loading...
;
+ const hasActiveFilters = Boolean(debouncedQuery || company || jobTitle);
+
+ // Reset to page 1 whenever a filter changes — but not on the initial mount,
+ // where `page` may have been hydrated from the URL.
+ useEffect(() => {
+ if (isFirstFilterRun.current) {
+ isFirstFilterRun.current = false;
+ return;
+ }
+ setPage(1);
+ }, [debouncedQuery, company, jobTitle]);
+
+ // Clamp page into range once the full dataset (and therefore `pages`) is known,
+ // e.g. a shared URL with ?page=999 against a filter that only has 2 pages.
+ useEffect(() => {
+ if (pages > 0 && page > pages) {
+ setPage(pages);
+ }
+ }, [pages, page]);
+
+ // Keep the URL in sync with search/filter/page state, omitting empty params.
+ useEffect(() => {
+ const params = {};
+ if (debouncedQuery) params.search = debouncedQuery;
+ if (company) params.company = company;
+ if (jobTitle) params.jobTitle = jobTitle;
+ if (page > 1) params.page = String(page);
+ setSearchParams(params, { replace: true });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [debouncedQuery, company, jobTitle, page]);
+
+ const clearFilters = () => {
+ setRawQuery("");
+ setCompany("");
+ setJobTitle("");
+ };
+
+ if (loading) return Loading...
;
if (error) return Error: {error}
;
return (
@@ -77,110 +137,200 @@ function ArmorMembers() {
description="Browse and connect with AI builders, researchers, founders, and operators across the Anote community."
path="/community/members"
/>
-
-
Community Members
-
{
- setQuery(e.target.value);
- setPage(1);
- }}
- className="w-full sm:w-72 px-4 py-2 rounded-md bg-gray-800 border border-gray-700 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
- />
+
+
+
Community Members
+
+
+
+
+ Search members
+
+
setRawQuery(e.target.value)}
+ className="w-full sm:w-64 px-4 py-2 rounded-md bg-gray-800 border border-gray-700 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
+
+
+
+
+
+
+
+
+
+
+ Clear filters
+
+
+
+ {(company || jobTitle) && (
+
+ {company && (
+ setCompany("")}
+ aria-label={`Remove Company filter: ${company}`}
+ className="flex items-center gap-1 px-3 py-1 rounded-full bg-blue-900/40 border border-blue-500 text-blue-200 text-xs"
+ >
+ Company: {company} ×
+
+ )}
+ {jobTitle && (
+ setJobTitle("")}
+ aria-label={`Remove Job Title filter: ${jobTitle}`}
+ className="flex items-center gap-1 px-3 py-1 rounded-full bg-blue-900/40 border border-blue-500 text-blue-200 text-xs"
+ >
+ Job Title: {jobTitle} ×
+
+ )}
+
+ )}
-
-
-
-
- First Name
- Last Name
- Company
- Job Title
- LinkedIn
- Share
-
-
-
- {slice.map((member, i) => (
-
- {member.firstName}
- {member.lastName}
- {member.company}
- {member.title}
-
- {member.linkedin ? (
-
- View ↗
-
- ) : (
- "-"
- )}
-
-
-
-
+ {filtered.length === 0 ? (
+
+
No members match the selected filters.
+
+ Clear filters
+
+
+ ) : (
+
+
+
+
+ First Name
+ Last Name
+ Company
+ Job Title
+ LinkedIn
+ Share
- ))}
-
-
-
+
+
+ {slice.map((member, i) => (
+
+ {member.firstName}
+ {member.lastName}
+ {member.company}
+ {member.title}
+
+ {member.linkedin ? (
+
+ View ↗
+
+ ) : (
+ "-"
+ )}
+
+
+
+
+
+ ))}
+
+
+
+ )}
- {pages > 1 && (
+ {filtered.length > 0 && (
- Showing {filtered.length} members
+ Showing {filtered.length} member{filtered.length === 1 ? "" : "s"}
-
-
setPage((prev) => Math.max(prev - 1, 1))}
- className={classNames(
- "px-3 py-2 rounded-md border text-lg",
- page === 1
- ? "bg-gray-700 border-gray-600 text-gray-400 cursor-not-allowed"
- : "bg-gray-800 border-gray-600 hover:bg-blue-500 hover:text-white transition"
- )}
- disabled={page === 1}
- >
- ←
-
-
-
- Page
- {
- let val = parseInt(e.target.value);
- if (!isNaN(val) && val >= 1 && val <= pages) {
- setPage(val);
- }
- }}
- className="w-16 text-center py-1 px-2 rounded-md bg-gray-800 border border-gray-600 text-white"
- />
- of {pages}
-
+ {pages > 1 && (
+
+ setPage((prev) => Math.max(prev - 1, 1))}
+ aria-label="Previous page"
+ className={classNames(
+ "px-3 py-2 rounded-md border text-lg",
+ page === 1
+ ? "bg-gray-700 border-gray-600 text-gray-400 cursor-not-allowed"
+ : "bg-gray-800 border-gray-600 hover:bg-blue-500 hover:text-white transition"
+ )}
+ disabled={page === 1}
+ >
+ ←
+
- setPage((prev) => Math.min(prev + 1, pages))}
- className={classNames(
- "px-3 py-2 rounded-md border text-lg",
- page === pages
- ? "bg-gray-700 border-gray-600 text-gray-400 cursor-not-allowed"
- : "bg-gray-800 border-gray-600 hover:bg-blue-500 hover:text-white transition"
- )}
- disabled={page === pages}
- >
- →
-
-
+
+
+ Page
+
+ {
+ let val = parseInt(e.target.value);
+ if (!isNaN(val) && val >= 1 && val <= pages) {
+ setPage(val);
+ }
+ }}
+ className="w-16 text-center py-1 px-2 rounded-md bg-gray-800 border border-gray-600 text-white"
+ />
+ of {pages}
+
+
+
setPage((prev) => Math.min(prev + 1, pages))}
+ aria-label="Next page"
+ className={classNames(
+ "px-3 py-2 rounded-md border text-lg",
+ page === pages
+ ? "bg-gray-700 border-gray-600 text-gray-400 cursor-not-allowed"
+ : "bg-gray-800 border-gray-600 hover:bg-blue-500 hover:text-white transition"
+ )}
+ disabled={page === pages}
+ >
+ →
+
+
+ )}
)}
@@ -188,4 +338,3 @@ function ArmorMembers() {
}
export default ArmorMembers;
-
diff --git a/frontend/src/landing_page/landing_page_armor/FilterCombobox.js b/frontend/src/landing_page/landing_page_armor/FilterCombobox.js
new file mode 100644
index 0000000..53e92c0
--- /dev/null
+++ b/frontend/src/landing_page/landing_page_armor/FilterCombobox.js
@@ -0,0 +1,178 @@
+import React, { useEffect, useId, useMemo, useRef, useState } from "react";
+
+// Searchable dropdown / autocomplete used for the Company and Job Title filters.
+//
+// allowFreeText=false (Company): committing only accepts an exact (case/whitespace
+// insensitive) match against `options` — typing something that isn't a known company
+// reverts to the last committed value instead of silently creating a new filter value.
+// allowFreeText=true (Job Title): committing accepts whatever text was typed, so
+// keyword matches like "CEO" work even though "CEO" alone may not be a full title.
+export default function FilterCombobox({
+ label,
+ placeholder,
+ options,
+ value,
+ onChange,
+ allowFreeText = false,
+}) {
+ const inputId = useId();
+ const listId = useId();
+ const [inputText, setInputText] = useState(value || "");
+ const [isOpen, setIsOpen] = useState(false);
+ const [highlightedIndex, setHighlightedIndex] = useState(-1);
+ const containerRef = useRef(null);
+ const isFirstValueSync = useRef(true);
+
+ // Re-sync displayed text when `value` changes externally (e.g. a filter chip's
+ // "x" button, or "Clear filters"). Skip the run that fires on mount — the
+ // initial state already derives from `value`, and running this unconditionally
+ // on mount can race a user's first keystroke and stomp it back to empty.
+ useEffect(() => {
+ if (isFirstValueSync.current) {
+ isFirstValueSync.current = false;
+ return;
+ }
+ setInputText(value || "");
+ }, [value]);
+
+ const filteredOptions = useMemo(() => {
+ const q = inputText.trim().toLowerCase();
+ if (!q) return options;
+ return options.filter((option) => option.toLowerCase().includes(q));
+ }, [options, inputText]);
+
+ useEffect(() => {
+ setHighlightedIndex(filteredOptions.length > 0 ? 0 : -1);
+ }, [filteredOptions]);
+
+ useEffect(() => {
+ function handleClickOutside(event) {
+ if (containerRef.current && !containerRef.current.contains(event.target)) {
+ setIsOpen(false);
+ }
+ }
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }, []);
+
+ const commitValue = (nextValue) => {
+ const trimmed = (nextValue || "").trim();
+ onChange(trimmed);
+ setInputText(trimmed);
+ setIsOpen(false);
+ };
+
+ const revertOrCommitFreeText = () => {
+ if (allowFreeText) {
+ commitValue(inputText);
+ return;
+ }
+ const exactMatch = options.find(
+ (option) => option.toLowerCase() === inputText.trim().toLowerCase()
+ );
+ commitValue(exactMatch || value || "");
+ };
+
+ const handleKeyDown = (e) => {
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setIsOpen(true);
+ setHighlightedIndex((i) => Math.min(i + 1, filteredOptions.length - 1));
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setHighlightedIndex((i) => Math.max(i - 1, 0));
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ if (allowFreeText) {
+ // Free-text keyword mode (Job Title): commit exactly what was typed so
+ // "CEO" filters by keyword instead of jumping to the first suggestion.
+ commitValue(inputText);
+ } else if (isOpen && highlightedIndex >= 0 && filteredOptions[highlightedIndex]) {
+ commitValue(filteredOptions[highlightedIndex]);
+ } else {
+ revertOrCommitFreeText();
+ }
+ } else if (e.key === "Escape") {
+ setIsOpen(false);
+ }
+ };
+
+ const handleBlur = () => {
+ // Runs after onMouseDown for option clicks (which preventDefault the blur),
+ // so this only fires for genuine click-away / tab-away.
+ setTimeout(revertOrCommitFreeText, 100);
+ };
+
+ const clear = () => {
+ onChange("");
+ setInputText("");
+ setIsOpen(false);
+ };
+
+ return (
+
+
+ {label}
+
+
+ setIsOpen(true)}
+ onChange={(e) => {
+ setInputText(e.target.value);
+ setIsOpen(true);
+ }}
+ onKeyDown={handleKeyDown}
+ onBlur={handleBlur}
+ className="w-full px-4 py-2 pr-8 rounded-md bg-gray-800 border border-gray-700 text-white placeholder-gray-500 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
+ {value && (
+
+ ×
+
+ )}
+
+ {isOpen && filteredOptions.length > 0 && (
+
+ {filteredOptions.map((option, index) => (
+ {
+ e.preventDefault();
+ commitValue(option);
+ }}
+ onMouseEnter={() => setHighlightedIndex(index)}
+ className={
+ index === highlightedIndex
+ ? "px-3 py-2 cursor-pointer text-sm bg-blue-600 text-white"
+ : "px-3 py-2 cursor-pointer text-sm text-gray-300 hover:bg-gray-700"
+ }
+ >
+ {option}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/landing_page/landing_page_armor/memberFilters.js b/frontend/src/landing_page/landing_page_armor/memberFilters.js
new file mode 100644
index 0000000..0e964df
--- /dev/null
+++ b/frontend/src/landing_page/landing_page_armor/memberFilters.js
@@ -0,0 +1,61 @@
+// Pure helpers for the Members page search + Company/Job Title filters.
+// Kept framework-free so they can be unit tested without rendering React.
+
+export function normalizeValue(value) {
+ return (value || "").toString().trim();
+}
+
+export function normalizeForCompare(value) {
+ return normalizeValue(value).toLowerCase();
+}
+
+// Unique, sorted, blank-free option list for a given member field (e.g. "company" or "title").
+// Dedupes case-insensitively/whitespace-insensitively, keeping the first-seen casing as the label.
+export function getUniqueOptions(members, field) {
+ const seen = new Map();
+ members.forEach((member) => {
+ const raw = normalizeValue(member[field]);
+ if (!raw) return;
+ const key = raw.toLowerCase();
+ if (!seen.has(key)) {
+ seen.set(key, raw);
+ }
+ });
+ return Array.from(seen.values()).sort((a, b) =>
+ a.localeCompare(b, undefined, { sensitivity: "base" })
+ );
+}
+
+export function matchesGeneralSearch(member, query) {
+ const q = normalizeForCompare(query);
+ if (!q) return true;
+ return (
+ normalizeForCompare(member.firstName).includes(q) ||
+ normalizeForCompare(member.lastName).includes(q) ||
+ normalizeForCompare(member.company).includes(q) ||
+ normalizeForCompare(member.title).includes(q)
+ );
+}
+
+// Company: exact match after trim + case-fold. Never fuzzy-merges distinct company names.
+export function matchesCompanyFilter(member, company) {
+ const target = normalizeForCompare(company);
+ if (!target) return true;
+ return normalizeForCompare(member.company) === target;
+}
+
+// Job Title: case-insensitive "contains" so "CEO" matches "Founder / CEO", "Founder & CEO", etc.
+export function matchesJobTitleFilter(member, jobTitle) {
+ const target = normalizeForCompare(jobTitle);
+ if (!target) return true;
+ return normalizeForCompare(member.title).includes(target);
+}
+
+export function applyMemberFilters(members, { query, company, jobTitle } = {}) {
+ return members.filter(
+ (member) =>
+ matchesGeneralSearch(member, query) &&
+ matchesCompanyFilter(member, company) &&
+ matchesJobTitleFilter(member, jobTitle)
+ );
+}
diff --git a/frontend/src/setupTests.js b/frontend/src/setupTests.js
new file mode 100644
index 0000000..9cff928
--- /dev/null
+++ b/frontend/src/setupTests.js
@@ -0,0 +1,3 @@
+// jest-dom adds custom jest matchers for asserting on DOM nodes.
+// https://github.com/testing-library/jest-dom
+import "@testing-library/jest-dom";
diff --git a/frontend/src/util/useDebouncedValue.js b/frontend/src/util/useDebouncedValue.js
new file mode 100644
index 0000000..242febe
--- /dev/null
+++ b/frontend/src/util/useDebouncedValue.js
@@ -0,0 +1,14 @@
+import { useEffect, useState } from "react";
+
+// Returns `value`, but only updates after it has been stable for `delayMs`.
+// Used to avoid re-filtering/re-rendering on every keystroke.
+export default function useDebouncedValue(value, delayMs = 250) {
+ const [debounced, setDebounced] = useState(value);
+
+ useEffect(() => {
+ const timer = setTimeout(() => setDebounced(value), delayMs);
+ return () => clearTimeout(timer);
+ }, [value, delayMs]);
+
+ return debounced;
+}