diff --git a/app/en/resources/glossary/page.mdx b/app/en/resources/glossary/page.mdx
index f46eada38..a550a20ab 100644
--- a/app/en/resources/glossary/page.mdx
+++ b/app/en/resources/glossary/page.mdx
@@ -174,7 +174,7 @@ _Learn more about [tool executions](/guides/tool-calling)._
### Standard and Pro Tool Executions
-Arcade tools are divided into 2 categories: Standard and Pro. While all tools have some cost for Arcade to run, Pro tools are significantly more costly — either due to infrastructure costs, the complexity of the tool, or a cost imposed by the provider of the tool. Pro tools cost more to execute and have different limits.
+Arcade tools are divided into 2 categories: Standard and Pro. While all tools have some cost for Arcade to run, Pro tools are more costly—either due to infrastructure costs, the complexity of the tool, or a cost imposed by the provider of the tool. Pro tools cost more to execute and have different limits.
#### Standard Tool Executions
@@ -182,15 +182,17 @@ Standard tools are the default tier and cover the majority of Arcade's catalog.
#### Pro Tool Executions
-Pro tools incur materially higher operational cost than Standard tools due to underlying infrastructure (e.g., compute-intensive sandboxes), provider-imposed fees (e.g., per-call API charges from data providers), or tool complexity. Each invocation of a Pro tool counts as one Pro Tool Execution against your plan's monthly Pro allowance, with any overage billed per execution at the Pro rate listed on the [pricing page](https://www.arcade.dev/pricing). Arcade may reclassify tools between Standard and Pro from time to time as the underlying cost structure of a tool changes; any such reclassification applies prospectively.
+Pro tools incur materially higher operational cost than Standard tools due to underlying infrastructure (for example, compute-intensive sandboxes), provider-imposed fees (for example, per-call API charges from data providers), or tool complexity. Each invocation of a Pro tool counts as one Pro Tool Execution against your plan's monthly Pro allowance, with any overage billed per execution at the Pro rate listed on the [pricing page](https://www.arcade.dev/pricing). Arcade may reclassify tools between Standard and Pro from time to time as the underlying cost structure of a tool changes; any such reclassification applies prospectively.
-As of 04/24, Pro tools include: E2B, Firecrawl, Google Finance, Google Flights, Google Hotels, Google Jobs, Google Maps, Google News, Google Search, Google Shopping, Imgflip, Walmart, and YouTube.
+See the current list of [Pro tools](/en/resources/integrations?label=pro#list).
### Bring Your Own Credentials (BYOC)
-Bring Your Own Credentials (BYOC) is a feature that allows you to use your own credentials to access Pro tools. This changes the cost of the tool execution, as you will be charged directly by the provider of the tool, rather than relying on Arcade to pay the bill for you. In exchange, the tool execution will be billed at the Standard rate. As of 04/26/2026, most credentials required for Pro tools are API Keys specific to the service being accessed.
+Bring Your Own Credentials (BYOC) is a feature that allows you to use your own credentials to access Pro tools. This changes the cost of the tool execution, as you will be charged directly by the provider of the tool, rather than relying on Arcade to pay the bill for you. In exchange, the tool execution will be billed at the Standard rate. Most credentials required for Pro tools are API Keys specific to the service being accessed.
-To set your own credentials, set the requisite secret(s) within the Arcade Dashboard Secrets page, overwriting the default 'static' credentials. You can also set the secrets using the Arcade CLI.
+To set your own credentials, set the requisite secrets within the Arcade Dashboard Secrets page, overwriting the default 'static' credentials. You can also set the secrets using the Arcade CLI.
+
+See the current list of [BYOC-eligible tools](/en/resources/integrations?label=byoc#list).
## Tool Execution and Tool Development
diff --git a/app/en/resources/integrations/components/filter-params.ts b/app/en/resources/integrations/components/filter-params.ts
new file mode 100644
index 000000000..432088986
--- /dev/null
+++ b/app/en/resources/integrations/components/filter-params.ts
@@ -0,0 +1,136 @@
+import type { ToolkitCategory, ToolkitType } from "@arcadeai/design-system";
+
+const TOOLKIT_TYPES: readonly ToolkitType[] = [
+ "arcade",
+ "arcade_starter",
+ "verified",
+ "community",
+ "auth",
+];
+
+const TOOLKIT_CATEGORIES: readonly ToolkitCategory[] = [
+ "all",
+ "productivity",
+ "social",
+ "development",
+ "entertainment",
+ "search",
+ "payments",
+ "sales",
+ "databases",
+ "customer-support",
+];
+
+export const PARAM_CATEGORY = "category";
+export const PARAM_TYPE = "type";
+export const PARAM_LABEL = "label";
+export const PARAM_SEARCH = "q";
+
+// Tool labels are multi-value: a tool can carry zero, one, or both labels.
+// Encoded in the URL as repeated `label=...` keys, e.g. `?label=pro&label=byoc`.
+export type Label = "pro" | "byoc";
+const LABELS: readonly Label[] = ["pro", "byoc"];
+
+export function isLabel(value: string): value is Label {
+ return (LABELS as readonly string[]).includes(value);
+}
+
+export type FilterState = {
+ selectedCategory: ToolkitCategory;
+ selectedType: ToolkitType | "all";
+ filterByPro: boolean;
+ filterByByoc: boolean;
+ searchQuery: string;
+};
+
+export const DEFAULT_FILTER_STATE: FilterState = {
+ selectedCategory: "all",
+ selectedType: "all",
+ filterByPro: false,
+ filterByByoc: false,
+ searchQuery: "",
+};
+
+export function isToolkitType(value: string): value is ToolkitType {
+ return (TOOLKIT_TYPES as readonly string[]).includes(value);
+}
+
+export function isToolkitCategory(value: string): value is ToolkitCategory {
+ return (TOOLKIT_CATEGORIES as readonly string[]).includes(value);
+}
+
+type ParamsInput = string | URLSearchParams | null | undefined;
+
+function toSearchParams(input: ParamsInput): URLSearchParams {
+ if (input instanceof URLSearchParams) {
+ return input;
+ }
+ return new URLSearchParams(input ?? "");
+}
+
+export function parseFiltersFromParams(input: ParamsInput): FilterState {
+ const params = toSearchParams(input);
+ const state: FilterState = { ...DEFAULT_FILTER_STATE };
+
+ const category = params.get(PARAM_CATEGORY);
+ if (category && isToolkitCategory(category)) {
+ state.selectedCategory = category;
+ }
+
+ const type = params.get(PARAM_TYPE);
+ if (type && isToolkitType(type)) {
+ state.selectedType = type;
+ }
+
+ // Unknown labels (e.g. `?label=bogus`) are silently ignored.
+ for (const label of params.getAll(PARAM_LABEL)) {
+ if (!isLabel(label)) {
+ continue;
+ }
+ if (label === "pro") {
+ state.filterByPro = true;
+ } else if (label === "byoc") {
+ state.filterByByoc = true;
+ }
+ }
+
+ const q = params.get(PARAM_SEARCH);
+ if (q) {
+ state.searchQuery = q;
+ }
+
+ return state;
+}
+
+export function serializeFiltersToParams(state: FilterState): string {
+ const params = new URLSearchParams();
+
+ if (state.selectedCategory !== "all") {
+ params.set(PARAM_CATEGORY, state.selectedCategory);
+ }
+ if (state.selectedType !== "all") {
+ params.set(PARAM_TYPE, state.selectedType);
+ }
+ // `append` (not `set`) so multiple labels round-trip as repeated keys.
+ if (state.filterByPro) {
+ params.append(PARAM_LABEL, "pro");
+ }
+ if (state.filterByByoc) {
+ params.append(PARAM_LABEL, "byoc");
+ }
+ if (state.searchQuery) {
+ params.set(PARAM_SEARCH, state.searchQuery);
+ }
+
+ return params.toString();
+}
+
+export function hasActiveFilters(state: FilterState): boolean {
+ return (
+ state.selectedCategory !== "all" ||
+ state.selectedType !== "all" ||
+ state.filterByPro ||
+ state.filterByByoc ||
+ state.searchQuery !== ""
+ );
+}
diff --git a/app/en/resources/integrations/components/filters-bar.tsx b/app/en/resources/integrations/components/filters-bar.tsx
index 9fc7024f2..044f4e47f 100644
--- a/app/en/resources/integrations/components/filters-bar.tsx
+++ b/app/en/resources/integrations/components/filters-bar.tsx
@@ -16,7 +16,7 @@ import {
import { Check, Filter, FolderOpen, Layers, Search, X } from "lucide-react";
import type React from "react";
import { TYPE_CONFIG } from "./type-config";
-import { useFilterStore } from "./use-toolkit-filters";
+import { useFilters } from "./use-filters";
type FiltersBarProps = {
resultsCount: number;
@@ -35,7 +35,7 @@ export function FiltersBar({ resultsCount }: FiltersBarProps) {
searchQuery,
setSearchQuery,
clearAllFilters,
- } = useFilterStore();
+ } = useFilters();
const hasActiveFilters =
selectedCategory !== "all" ||
diff --git a/app/en/resources/integrations/components/toolkits-client.tsx b/app/en/resources/integrations/components/toolkits-client.tsx
index 6481ec82f..8484bc42f 100644
--- a/app/en/resources/integrations/components/toolkits-client.tsx
+++ b/app/en/resources/integrations/components/toolkits-client.tsx
@@ -17,7 +17,8 @@ import {
import { FiltersBar } from "./filters-bar";
import { ToolCard } from "./tool-card";
import { TYPE_CONFIG, TYPE_DESCRIPTIONS } from "./type-config";
-import { useFilterStore, useToolkitFilters } from "./use-toolkit-filters";
+import { useFilters } from "./use-filters";
+import { useToolkitFilters } from "./use-toolkit-filters";
type ToolkitsClientProps = {
toolkits: ToolkitWithDocsLink[];
@@ -66,7 +67,7 @@ function getToolkitIconWithFallback(
}
export default function ToolkitsClient({ toolkits }: ToolkitsClientProps) {
- const clearAllFilters = useFilterStore((state) => state.clearAllFilters);
+ const { clearAllFilters } = useFilters();
const { hasActiveFilters, filteredToolkits, resultsCount } =
useToolkitFilters(toolkits);
@@ -156,7 +157,7 @@ export default function ToolkitsClient({ toolkits }: ToolkitsClientProps) {
-
+
diff --git a/app/en/resources/integrations/components/toolkits.tsx b/app/en/resources/integrations/components/toolkits.tsx
index e76f680d2..b50c97d45 100644
--- a/app/en/resources/integrations/components/toolkits.tsx
+++ b/app/en/resources/integrations/components/toolkits.tsx
@@ -1,4 +1,5 @@
import { TOOLKITS, type Toolkit } from "@arcadeai/design-system";
+import { Suspense } from "react";
import { PARTNER_TOOLKITS } from "@/app/_data/partner-toolkits";
import { readToolkitData } from "@/app/_lib/toolkit-data";
import {
@@ -48,5 +49,12 @@ const getToolkitsWithDocsLinks = async (): Promise => {
export default async function Toolkits() {
const toolkits = await getToolkitsWithDocsLinks();
- return ;
+ // Suspense boundary is required because ToolkitsClient calls
+ // useSearchParams(); without it, Next.js bails out of static prerendering
+ // for the whole page.
+ return (
+
+
+
+ );
}
diff --git a/app/en/resources/integrations/components/use-filters.ts b/app/en/resources/integrations/components/use-filters.ts
new file mode 100644
index 000000000..b312bc2d1
--- /dev/null
+++ b/app/en/resources/integrations/components/use-filters.ts
@@ -0,0 +1,107 @@
+"use client";
+
+import type { ToolkitCategory, ToolkitType } from "@arcadeai/design-system";
+import { usePathname, useSearchParams } from "next/navigation";
+import { useCallback, useMemo } from "react";
+import {
+ DEFAULT_FILTER_STATE,
+ type FilterState,
+ parseFiltersFromParams,
+ serializeFiltersToParams,
+} from "./filter-params";
+
+type HistoryMethod = "push" | "replace";
+
+/**
+ * Two-way binding between filter UI and URL query params, with the URL as the
+ * single source of truth.
+ *
+ * - Read: `useSearchParams()` re-renders this hook whenever the URL changes
+ * (including back/forward and `pushState`/`replaceState` calls), so the UI
+ * always reflects the current URL.
+ * - Write: setters compute the next state, serialize to a query string, and
+ * call `window.history.pushState` (or `replaceState` for the search input,
+ * to avoid one history entry per keystroke). Next.js picks up the change
+ * and re-renders subscribers automatically.
+ */
+export function useFilters() {
+ const searchParams = useSearchParams();
+ const pathname = usePathname();
+
+ const state = useMemo(
+ () => parseFiltersFromParams(searchParams?.toString() ?? ""),
+ [searchParams]
+ );
+
+ const writeState = useCallback(
+ (next: FilterState, method: HistoryMethod = "push") => {
+ const qs = serializeFiltersToParams(next);
+ const url = qs ? `${pathname}?${qs}` : pathname;
+ const currentQs = searchParams?.toString() ?? "";
+ const current = currentQs ? `${pathname}?${currentQs}` : pathname;
+
+ if (url === current) {
+ return;
+ }
+
+ if (method === "replace") {
+ window.history.replaceState(null, "", url);
+ } else {
+ window.history.pushState(null, "", url);
+ }
+ },
+ [pathname, searchParams]
+ );
+
+ const setSelectedCategory = useCallback(
+ (category: ToolkitCategory) => {
+ writeState({ ...state, selectedCategory: category });
+ },
+ [state, writeState]
+ );
+
+ const setSelectedType = useCallback(
+ (type: ToolkitType | "all") => {
+ writeState({ ...state, selectedType: type });
+ },
+ [state, writeState]
+ );
+
+ const setFilterByPro = useCallback(
+ (value: boolean) => {
+ writeState({ ...state, filterByPro: value });
+ },
+ [state, writeState]
+ );
+
+ const setFilterByByoc = useCallback(
+ (value: boolean) => {
+ writeState({ ...state, filterByByoc: value });
+ },
+ [state, writeState]
+ );
+
+ // Search-query updates use replaceState to avoid one history entry per
+ // keystroke. Discrete filter changes above use pushState so back/forward
+ // steps through them.
+ const setSearchQuery = useCallback(
+ (query: string) => {
+ writeState({ ...state, searchQuery: query }, "replace");
+ },
+ [state, writeState]
+ );
+
+ const clearAllFilters = useCallback(() => {
+ writeState(DEFAULT_FILTER_STATE);
+ }, [writeState]);
+
+ return {
+ ...state,
+ setSelectedCategory,
+ setSelectedType,
+ setFilterByPro,
+ setFilterByByoc,
+ setSearchQuery,
+ clearAllFilters,
+ };
+}
diff --git a/app/en/resources/integrations/components/use-toolkit-filters.ts b/app/en/resources/integrations/components/use-toolkit-filters.ts
index 2d43686ff..2a15c1385 100644
--- a/app/en/resources/integrations/components/use-toolkit-filters.ts
+++ b/app/en/resources/integrations/components/use-toolkit-filters.ts
@@ -1,7 +1,7 @@
import type { Toolkit, ToolkitType } from "@arcadeai/design-system";
import { useDebounce } from "@uidotdev/usehooks";
import { useMemo } from "react";
-import { create } from "zustand";
+import { useFilters } from "./use-filters";
const DEFAULT_PRIORITY = 5;
const DEBOUNCE_TIME = 300;
@@ -45,41 +45,6 @@ const compareToolkits = (a: T, b: T): number => {
return a.label.localeCompare(b.label);
};
-type FilterState = {
- selectedCategory: string;
- selectedType: ToolkitType | "all";
- filterByPro: boolean;
- filterByByoc: boolean;
- searchQuery: string;
- setSelectedCategory: (category: string) => void;
- setSelectedType: (type: ToolkitType | "all") => void;
- setFilterByPro: (value: boolean) => void;
- setFilterByByoc: (value: boolean) => void;
- setSearchQuery: (query: string) => void;
- clearAllFilters: () => void;
-};
-
-export const useFilterStore = create((set) => ({
- selectedCategory: "all",
- selectedType: "all",
- filterByPro: false,
- filterByByoc: false,
- searchQuery: "",
- setSelectedCategory: (category) => set({ selectedCategory: category }),
- setSelectedType: (type) => set({ selectedType: type }),
- setFilterByPro: (value) => set({ filterByPro: value }),
- setFilterByByoc: (value) => set({ filterByByoc: value }),
- setSearchQuery: (query) => set({ searchQuery: query }),
- clearAllFilters: () =>
- set({
- selectedCategory: "all",
- selectedType: "all",
- filterByPro: false,
- filterByByoc: false,
- searchQuery: "",
- }),
-}));
-
export function useToolkitFilters(toolkits: T[]) {
const {
selectedCategory,
@@ -87,7 +52,7 @@ export function useToolkitFilters(toolkits: T[]) {
filterByPro,
filterByByoc,
searchQuery,
- } = useFilterStore();
+ } = useFilters();
const debouncedSearchQuery = useDebounce(searchQuery, DEBOUNCE_TIME);
diff --git a/tests/filter-params.test.ts b/tests/filter-params.test.ts
new file mode 100644
index 000000000..3169bfe87
--- /dev/null
+++ b/tests/filter-params.test.ts
@@ -0,0 +1,315 @@
+import { describe, expect, test } from "vitest";
+import {
+ DEFAULT_FILTER_STATE,
+ type FilterState,
+ hasActiveFilters,
+ isLabel,
+ isToolkitCategory,
+ isToolkitType,
+ parseFiltersFromParams,
+ serializeFiltersToParams,
+} from "../app/en/resources/integrations/components/filter-params";
+
+describe("isToolkitType", () => {
+ test("accepts known toolkit types", () => {
+ expect(isToolkitType("arcade")).toBe(true);
+ expect(isToolkitType("arcade_starter")).toBe(true);
+ expect(isToolkitType("verified")).toBe(true);
+ expect(isToolkitType("community")).toBe(true);
+ expect(isToolkitType("auth")).toBe(true);
+ });
+
+ test("rejects unknown values", () => {
+ expect(isToolkitType("bogus")).toBe(false);
+ expect(isToolkitType("ARCADE")).toBe(false);
+ expect(isToolkitType("")).toBe(false);
+ });
+});
+
+describe("isToolkitCategory", () => {
+ test("accepts known categories", () => {
+ expect(isToolkitCategory("all")).toBe(true);
+ expect(isToolkitCategory("productivity")).toBe(true);
+ expect(isToolkitCategory("social")).toBe(true);
+ expect(isToolkitCategory("development")).toBe(true);
+ expect(isToolkitCategory("customer-support")).toBe(true);
+ });
+
+ test("rejects unknown values", () => {
+ expect(isToolkitCategory("bogus")).toBe(false);
+ expect(isToolkitCategory("Productivity")).toBe(false);
+ expect(isToolkitCategory("")).toBe(false);
+ });
+});
+
+describe("isLabel", () => {
+ test("accepts known labels", () => {
+ expect(isLabel("pro")).toBe(true);
+ expect(isLabel("byoc")).toBe(true);
+ });
+
+ test("rejects unknown values", () => {
+ expect(isLabel("PRO")).toBe(false);
+ expect(isLabel("bogus")).toBe(false);
+ expect(isLabel("")).toBe(false);
+ expect(isLabel("1")).toBe(false);
+ });
+});
+
+describe("parseFiltersFromParams", () => {
+ test("returns defaults for no params", () => {
+ expect(parseFiltersFromParams("")).toEqual(DEFAULT_FILTER_STATE);
+ expect(parseFiltersFromParams(null)).toEqual(DEFAULT_FILTER_STATE);
+ expect(parseFiltersFromParams(undefined)).toEqual(DEFAULT_FILTER_STATE);
+ });
+
+ test("parses pro label", () => {
+ expect(parseFiltersFromParams("?label=pro")).toEqual({
+ ...DEFAULT_FILTER_STATE,
+ filterByPro: true,
+ });
+ });
+
+ test("parses byoc label", () => {
+ expect(parseFiltersFromParams("?label=byoc")).toEqual({
+ ...DEFAULT_FILTER_STATE,
+ filterByByoc: true,
+ });
+ });
+
+ test("parses pro + byoc together (repeated key)", () => {
+ expect(parseFiltersFromParams("?label=pro&label=byoc")).toEqual({
+ ...DEFAULT_FILTER_STATE,
+ filterByPro: true,
+ filterByByoc: true,
+ });
+ });
+
+ test("label order does not matter", () => {
+ expect(parseFiltersFromParams("?label=byoc&label=pro")).toEqual({
+ ...DEFAULT_FILTER_STATE,
+ filterByPro: true,
+ filterByByoc: true,
+ });
+ });
+
+ test("parses category", () => {
+ expect(parseFiltersFromParams("?category=development")).toEqual({
+ ...DEFAULT_FILTER_STATE,
+ selectedCategory: "development",
+ });
+ });
+
+ test("parses type", () => {
+ expect(parseFiltersFromParams("?type=arcade")).toEqual({
+ ...DEFAULT_FILTER_STATE,
+ selectedType: "arcade",
+ });
+ });
+
+ test("parses search query", () => {
+ expect(parseFiltersFromParams("?q=gmail")).toEqual({
+ ...DEFAULT_FILTER_STATE,
+ searchQuery: "gmail",
+ });
+ });
+
+ test("parses all params at once", () => {
+ expect(
+ parseFiltersFromParams(
+ "?category=productivity&type=verified&label=pro&label=byoc&q=slack"
+ )
+ ).toEqual({
+ selectedCategory: "productivity",
+ selectedType: "verified",
+ filterByPro: true,
+ filterByByoc: true,
+ searchQuery: "slack",
+ });
+ });
+
+ test("ignores invalid type values", () => {
+ expect(parseFiltersFromParams("?type=bogus")).toEqual(DEFAULT_FILTER_STATE);
+ });
+
+ test("ignores invalid category values", () => {
+ expect(parseFiltersFromParams("?category=bogus")).toEqual(
+ DEFAULT_FILTER_STATE
+ );
+ expect(parseFiltersFromParams("?category=Productivity")).toEqual(
+ DEFAULT_FILTER_STATE
+ );
+ });
+
+ test("ignores category=all (already the default)", () => {
+ expect(parseFiltersFromParams("?category=all")).toEqual(
+ DEFAULT_FILTER_STATE
+ );
+ });
+
+ test("ignores unknown label values", () => {
+ expect(parseFiltersFromParams("?label=bogus")).toEqual(
+ DEFAULT_FILTER_STATE
+ );
+ expect(parseFiltersFromParams("?label=PRO")).toEqual(DEFAULT_FILTER_STATE);
+ expect(parseFiltersFromParams("?label=")).toEqual(DEFAULT_FILTER_STATE);
+ });
+
+ test("applies known labels and ignores unknown ones in the same URL", () => {
+ expect(parseFiltersFromParams("?label=pro&label=bogus")).toEqual({
+ ...DEFAULT_FILTER_STATE,
+ filterByPro: true,
+ });
+ });
+
+ test("ignores duplicate label values", () => {
+ expect(parseFiltersFromParams("?label=pro&label=pro")).toEqual({
+ ...DEFAULT_FILTER_STATE,
+ filterByPro: true,
+ });
+ });
+
+ test("ignores unknown params gracefully", () => {
+ expect(parseFiltersFromParams("?foo=bar&baz=1")).toEqual(
+ DEFAULT_FILTER_STATE
+ );
+ });
+
+ test("does not treat legacy ?pro=1 as truthy", () => {
+ // The old URL shape (`?pro=1&byoc=1`) has been retired; only `?label=...`
+ // is recognized. Legacy URLs degrade to defaults rather than throwing.
+ expect(parseFiltersFromParams("?pro=1&byoc=1")).toEqual(
+ DEFAULT_FILTER_STATE
+ );
+ });
+
+ test("accepts URLSearchParams instance", () => {
+ const params = new URLSearchParams();
+ params.append("label", "pro");
+ params.set("q", "gmail");
+ expect(parseFiltersFromParams(params)).toEqual({
+ ...DEFAULT_FILTER_STATE,
+ filterByPro: true,
+ searchQuery: "gmail",
+ });
+ });
+});
+
+describe("serializeFiltersToParams", () => {
+ test("returns empty string for default state", () => {
+ expect(serializeFiltersToParams(DEFAULT_FILTER_STATE)).toBe("");
+ });
+
+ test("serializes pro label", () => {
+ expect(
+ serializeFiltersToParams({ ...DEFAULT_FILTER_STATE, filterByPro: true })
+ ).toBe("label=pro");
+ });
+
+ test("serializes byoc label", () => {
+ expect(
+ serializeFiltersToParams({ ...DEFAULT_FILTER_STATE, filterByByoc: true })
+ ).toBe("label=byoc");
+ });
+
+ test("serializes both labels as repeated keys", () => {
+ expect(
+ serializeFiltersToParams({
+ ...DEFAULT_FILTER_STATE,
+ filterByPro: true,
+ filterByByoc: true,
+ })
+ ).toBe("label=pro&label=byoc");
+ });
+
+ test("serializes category", () => {
+ expect(
+ serializeFiltersToParams({
+ ...DEFAULT_FILTER_STATE,
+ selectedCategory: "development",
+ })
+ ).toBe("category=development");
+ });
+
+ test("serializes type", () => {
+ expect(
+ serializeFiltersToParams({
+ ...DEFAULT_FILTER_STATE,
+ selectedType: "arcade",
+ })
+ ).toBe("type=arcade");
+ });
+
+ test("serializes search query", () => {
+ expect(
+ serializeFiltersToParams({
+ ...DEFAULT_FILTER_STATE,
+ searchQuery: "gmail",
+ })
+ ).toBe("q=gmail");
+ });
+
+ test("serializes all filters at once", () => {
+ const qs = serializeFiltersToParams({
+ selectedCategory: "productivity",
+ selectedType: "verified",
+ filterByPro: true,
+ filterByByoc: true,
+ searchQuery: "slack",
+ });
+ const params = new URLSearchParams(qs);
+ expect(params.get("category")).toBe("productivity");
+ expect(params.get("type")).toBe("verified");
+ expect(params.getAll("label")).toEqual(["pro", "byoc"]);
+ expect(params.get("q")).toBe("slack");
+ });
+});
+
+describe("round-trip", () => {
+ test("serialize then parse produces equivalent filters", () => {
+ const state: FilterState = {
+ selectedCategory: "social",
+ selectedType: "community",
+ filterByPro: true,
+ filterByByoc: false,
+ searchQuery: "twitter",
+ };
+ const qs = serializeFiltersToParams(state);
+ expect(parseFiltersFromParams(`?${qs}`)).toEqual(state);
+ });
+
+ test("both labels round-trip", () => {
+ const state: FilterState = {
+ ...DEFAULT_FILTER_STATE,
+ filterByPro: true,
+ filterByByoc: true,
+ };
+ const qs = serializeFiltersToParams(state);
+ expect(qs).toBe("label=pro&label=byoc");
+ expect(parseFiltersFromParams(`?${qs}`)).toEqual(state);
+ });
+
+ test("default state round-trips to empty query string", () => {
+ const qs = serializeFiltersToParams(DEFAULT_FILTER_STATE);
+ expect(qs).toBe("");
+ expect(parseFiltersFromParams(qs)).toEqual(DEFAULT_FILTER_STATE);
+ });
+});
+
+describe("hasActiveFilters", () => {
+ test("returns false for default state", () => {
+ expect(hasActiveFilters(DEFAULT_FILTER_STATE)).toBe(false);
+ });
+
+ test("returns true when any filter is active", () => {
+ expect(
+ hasActiveFilters({ ...DEFAULT_FILTER_STATE, filterByPro: true })
+ ).toBe(true);
+ expect(
+ hasActiveFilters({ ...DEFAULT_FILTER_STATE, selectedCategory: "social" })
+ ).toBe(true);
+ expect(
+ hasActiveFilters({ ...DEFAULT_FILTER_STATE, searchQuery: "x" })
+ ).toBe(true);
+ });
+});