diff --git a/app/en/resources/glossary/page.mdx b/app/en/resources/glossary/page.mdx index f46eada38..40eeba4c5 100644 --- a/app/en/resources/glossary/page.mdx +++ b/app/en/resources/glossary/page.mdx @@ -184,14 +184,16 @@ Standard tools are the default tier and cover the majority of Arcade's catalog. 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. -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?pro=1). ### 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. +See the current list of [BYOC-eligible tools](/en/resources/integrations?byoc=1). + ## Tool Execution and Tool Development ```mermaid diff --git a/app/en/resources/integrations/components/toolkits-client.tsx b/app/en/resources/integrations/components/toolkits-client.tsx index 8f478fb17..067a06838 100644 --- a/app/en/resources/integrations/components/toolkits-client.tsx +++ b/app/en/resources/integrations/components/toolkits-client.tsx @@ -17,6 +17,7 @@ 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 { useUrlFilterSync } from "./use-url-filter-sync"; type ToolkitsClientProps = { toolkits: ToolkitWithDocsLink[]; @@ -63,6 +64,8 @@ function getToolkitIconWithFallback( } export default function ToolkitsClient({ toolkits }: ToolkitsClientProps) { + useUrlFilterSync(); + const clearAllFilters = useFilterStore((state) => state.clearAllFilters); const { hasActiveFilters, filteredToolkits, resultsCount } = diff --git a/app/en/resources/integrations/components/use-url-filter-sync.ts b/app/en/resources/integrations/components/use-url-filter-sync.ts new file mode 100644 index 000000000..91ef58896 --- /dev/null +++ b/app/en/resources/integrations/components/use-url-filter-sync.ts @@ -0,0 +1,148 @@ +"use client"; + +import type { ToolkitType } from "@arcadeai/design-system"; +import { useEffect, useRef } from "react"; +import { useFilterStore } from "./use-toolkit-filters"; + +const VALID_TYPES = new Set([ + "arcade", + "arcade_starter", + "verified", + "community", + "auth", +]); + +const PARAM_CATEGORY = "category"; +const PARAM_TYPE = "type"; +const PARAM_PRO = "pro"; +const PARAM_BYOC = "byoc"; +const PARAM_SEARCH = "q"; + +function isTruthy(value: string | null): boolean { + return value !== null && value !== "" && value !== "0" && value !== "false"; +} + +export type ParsedFilters = Partial<{ + selectedCategory: string; + selectedType: ToolkitType | "all"; + filterByPro: boolean; + filterByByoc: boolean; + searchQuery: string; +}>; + +export function parseFiltersFromParams(search: string): ParsedFilters { + const params = new URLSearchParams(search); + const result: ParsedFilters = {}; + + const category = params.get(PARAM_CATEGORY); + if (category && category !== "all") { + result.selectedCategory = category; + } + + const type = params.get(PARAM_TYPE); + if (type && VALID_TYPES.has(type)) { + result.selectedType = type as ToolkitType; + } + + if (params.has(PARAM_PRO) && isTruthy(params.get(PARAM_PRO))) { + result.filterByPro = true; + } + + if (params.has(PARAM_BYOC) && isTruthy(params.get(PARAM_BYOC))) { + result.filterByByoc = true; + } + + const q = params.get(PARAM_SEARCH); + if (q) { + result.searchQuery = q; + } + + return result; +} + +export type SerializableFilterState = { + selectedCategory: string; + selectedType: string; + filterByPro: boolean; + filterByByoc: boolean; + searchQuery: string; +}; + +export function serializeFiltersToParams( + state: SerializableFilterState +): 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); + } + if (state.filterByPro) { + params.set(PARAM_PRO, "1"); + } + if (state.filterByByoc) { + params.set(PARAM_BYOC, "1"); + } + if (state.searchQuery) { + params.set(PARAM_SEARCH, state.searchQuery); + } + + return params.toString(); +} + +function writeFiltersToUrl(state: SerializableFilterState): void { + const qs = serializeFiltersToParams(state); + const newUrl = qs + ? `${window.location.pathname}?${qs}` + : window.location.pathname; + + if (newUrl !== `${window.location.pathname}${window.location.search}`) { + window.history.replaceState(null, "", newUrl); + } +} + +/** + * Two-way sync between the Zustand filter store and URL query params. + * + * - Mount: URL → store (so shared/bookmarked links work) + * - Filter change: store → URL (via replaceState, no navigation) + * - Back/forward: URL → store (via popstate listener) + */ +export function useUrlFilterSync(): void { + const hydrated = useRef(false); + + useEffect(() => { + if (!hydrated.current) { + hydrated.current = true; + const fromUrl = parseFiltersFromParams(window.location.search); + if (Object.keys(fromUrl).length > 0) { + useFilterStore.setState(fromUrl); + } + } + + const unsubscribe = useFilterStore.subscribe((state) => { + writeFiltersToUrl(state); + }); + + const handlePopState = () => { + const defaults = { + selectedCategory: "all", + selectedType: "all" as const, + filterByPro: false, + filterByByoc: false, + searchQuery: "", + }; + const fromUrl = parseFiltersFromParams(window.location.search); + useFilterStore.setState({ ...defaults, ...fromUrl }); + }; + + window.addEventListener("popstate", handlePopState); + + return () => { + unsubscribe(); + window.removeEventListener("popstate", handlePopState); + }; + }, []); +} diff --git a/tests/url-filter-sync.test.ts b/tests/url-filter-sync.test.ts new file mode 100644 index 000000000..8782d38d4 --- /dev/null +++ b/tests/url-filter-sync.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, test } from "vitest"; +import { + parseFiltersFromParams, + type SerializableFilterState, + serializeFiltersToParams, +} from "../app/en/resources/integrations/components/use-url-filter-sync"; + +const DEFAULT_STATE: SerializableFilterState = { + selectedCategory: "all", + selectedType: "all", + filterByPro: false, + filterByByoc: false, + searchQuery: "", +}; + +describe("parseFiltersFromParams", () => { + test("returns empty object for no params", () => { + expect(parseFiltersFromParams("")).toEqual({}); + }); + + test("parses pro filter", () => { + expect(parseFiltersFromParams("?pro=1")).toEqual({ filterByPro: true }); + }); + + test("parses byoc filter", () => { + expect(parseFiltersFromParams("?byoc=1")).toEqual({ filterByByoc: true }); + }); + + test("parses pro + byoc together", () => { + expect(parseFiltersFromParams("?pro=1&byoc=1")).toEqual({ + filterByPro: true, + filterByByoc: true, + }); + }); + + test("parses category", () => { + expect(parseFiltersFromParams("?category=development")).toEqual({ + selectedCategory: "development", + }); + }); + + test("parses type", () => { + expect(parseFiltersFromParams("?type=arcade")).toEqual({ + selectedType: "arcade", + }); + }); + + test("parses search query", () => { + expect(parseFiltersFromParams("?q=gmail")).toEqual({ + searchQuery: "gmail", + }); + }); + + test("parses all params at once", () => { + expect( + parseFiltersFromParams( + "?category=productivity&type=verified&pro=1&byoc=1&q=slack" + ) + ).toEqual({ + selectedCategory: "productivity", + selectedType: "verified", + filterByPro: true, + filterByByoc: true, + searchQuery: "slack", + }); + }); + + test("ignores invalid type values", () => { + expect(parseFiltersFromParams("?type=bogus")).toEqual({}); + }); + + test("ignores falsy pro values", () => { + expect(parseFiltersFromParams("?pro=0")).toEqual({}); + expect(parseFiltersFromParams("?pro=false")).toEqual({}); + expect(parseFiltersFromParams("?pro=")).toEqual({}); + }); + + test("ignores category=all", () => { + expect(parseFiltersFromParams("?category=all")).toEqual({}); + }); + + test("ignores unknown params gracefully", () => { + expect(parseFiltersFromParams("?foo=bar&baz=1")).toEqual({}); + }); +}); + +describe("serializeFiltersToParams", () => { + test("returns empty string for default state", () => { + expect(serializeFiltersToParams(DEFAULT_STATE)).toBe(""); + }); + + test("serializes pro filter", () => { + expect( + serializeFiltersToParams({ ...DEFAULT_STATE, filterByPro: true }) + ).toBe("pro=1"); + }); + + test("serializes byoc filter", () => { + expect( + serializeFiltersToParams({ ...DEFAULT_STATE, filterByByoc: true }) + ).toBe("byoc=1"); + }); + + test("serializes category", () => { + expect( + serializeFiltersToParams({ + ...DEFAULT_STATE, + selectedCategory: "development", + }) + ).toBe("category=development"); + }); + + test("serializes type", () => { + expect( + serializeFiltersToParams({ + ...DEFAULT_STATE, + selectedType: "arcade", + }) + ).toBe("type=arcade"); + }); + + test("serializes search query", () => { + expect( + serializeFiltersToParams({ ...DEFAULT_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.get("pro")).toBe("1"); + expect(params.get("byoc")).toBe("1"); + expect(params.get("q")).toBe("slack"); + }); +}); + +describe("round-trip", () => { + test("serialize then parse produces equivalent filters", () => { + const state: SerializableFilterState = { + selectedCategory: "social", + selectedType: "community", + filterByPro: true, + filterByByoc: false, + searchQuery: "twitter", + }; + const qs = serializeFiltersToParams(state); + const parsed = parseFiltersFromParams(`?${qs}`); + expect(parsed).toEqual({ + selectedCategory: "social", + selectedType: "community", + filterByPro: true, + searchQuery: "twitter", + }); + }); + + test("default state round-trips to empty", () => { + const qs = serializeFiltersToParams(DEFAULT_STATE); + expect(qs).toBe(""); + const parsed = parseFiltersFromParams(qs); + expect(parsed).toEqual({}); + }); +});