From 715951ed23d85336c91f029976486f732dd2dde1 Mon Sep 17 00:00:00 2001 From: Paul Wackerow <54227730+wackerow@users.noreply.github.com> Date: Tue, 27 Jan 2026 13:50:46 -0500 Subject: [PATCH 1/4] feat(ui): add reusable FilterBar component - Add filter-bar-* i18n keys to common.json namespace - Create FilterBar component with searchable combobox (Command + Popover) - Add Storybook stories for component testing Co-Authored-By: Claude Opus 4.5 --- .../FilterBar/FilterBar.stories.tsx | 98 ++++++++++++ src/components/FilterBar/index.tsx | 146 ++++++++++++++++++ src/intl/en/common.json | 5 + 3 files changed, 249 insertions(+) create mode 100644 src/components/FilterBar/FilterBar.stories.tsx create mode 100644 src/components/FilterBar/index.tsx diff --git a/src/components/FilterBar/FilterBar.stories.tsx b/src/components/FilterBar/FilterBar.stories.tsx new file mode 100644 index 00000000000..0b237cd6cb0 --- /dev/null +++ b/src/components/FilterBar/FilterBar.stories.tsx @@ -0,0 +1,98 @@ +import { useState } from "react" +import type { Meta } from "@storybook/react" + +import FilterBar from "./" + +const meta = { + title: "Molecules / Navigation / FilterBar", + component: FilterBar, + parameters: { + layout: "fullscreen", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta + +export default meta + +const sampleItems = [ + { value: "defi", label: "DeFi" }, + { value: "nft", label: "NFTs" }, + { value: "gaming", label: "Gaming" }, + { value: "social", label: "Social" }, + { value: "identity", label: "Identity" }, +] + +const manyItems = [ + { value: "analytics", label: "Analytics" }, + { value: "bridges", label: "Bridges" }, + { value: "dao", label: "DAOs" }, + { value: "defi", label: "DeFi" }, + { value: "developer-tools", label: "Developer Tools" }, + { value: "gaming", label: "Gaming" }, + { value: "identity", label: "Identity" }, + { value: "infrastructure", label: "Infrastructure" }, + { value: "marketplaces", label: "Marketplaces" }, + { value: "nft", label: "NFTs" }, + { value: "payments", label: "Payments" }, + { value: "security", label: "Security" }, + { value: "social", label: "Social" }, + { value: "storage", label: "Storage" }, + { value: "wallets", label: "Wallets" }, +] + +export const Default = { + render: () => { + const [value, setValue] = useState() + const filteredCount = value ? 12 : 48 + + return ( + + ) + }, +} + +export const WithActiveFilter = { + render: () => { + const [value, setValue] = useState("defi") + const filteredCount = value ? 12 : 48 + + return ( + + ) + }, +} + +export const ManyItems = { + render: () => { + const [value, setValue] = useState() + const filteredCount = value ? 8 : 120 + + return ( + + ) + }, +} diff --git a/src/components/FilterBar/index.tsx b/src/components/FilterBar/index.tsx new file mode 100644 index 00000000000..a6b6751af66 --- /dev/null +++ b/src/components/FilterBar/index.tsx @@ -0,0 +1,146 @@ +"use client" + +import { useState } from "react" +import { Check, ChevronDown, X } from "lucide-react" +import { useTranslations } from "next-intl" + +import type { MatomoEventOptions } from "@/lib/types" + +import { Button } from "@/components/ui/buttons/Button" +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" + +import { trackCustomEvent } from "@/lib/utils/matomo" + +export type FilterBarProps = { + /** Items available for filtering */ + items: { value: string; label: string }[] + + /** Currently selected value (undefined = no filter) */ + value?: string + + /** Callback when selection changes */ + onValueChange: (value: string | undefined) => void + + /** Number of items after filtering */ + count: number + + /** Total number of items (unfiltered) */ + totalCount: number + + /** Optional Matomo tracking config */ + matomoEvent?: MatomoEventOptions +} + +export default function FilterBar({ + items, + value, + onValueChange, + count, + totalCount, + matomoEvent, +}: FilterBarProps) { + const t = useTranslations("common") + const [open, setOpen] = useState(false) + + const selectedLabel = items.find((item) => item.value === value)?.label + + const handleSelect = (selectedValue: string) => { + onValueChange(selectedValue) + setOpen(false) + if (matomoEvent) { + trackCustomEvent({ + ...matomoEvent, + eventName: `filter: ${selectedValue}`, + }) + } + } + + const handleClear = () => { + onValueChange(undefined) + if (matomoEvent) { + trackCustomEvent({ + ...matomoEvent, + eventName: "filter: cleared", + }) + } + } + + const COMBOBOX_ID = "filter-bar-listbox" + + const countDisplay = + count !== totalCount ? `${count}/${totalCount}` : `${count}` + + return ( +
+
+ + + + + + + + + {t("filter-bar-no-results")} + {items.map((item) => ( + handleSelect(item.value)} + className="flex items-center justify-between" + > + {item.label} + {value === item.value && ( + + )} + + ))} + + + + + + {value && ( + + )} +
+ +

+ {t("filter-bar-showing")}{" "} + ({countDisplay}) +

+
+ ) +} diff --git a/src/intl/en/common.json b/src/intl/en/common.json index 3df2a11e9e8..4558b90583c 100644 --- a/src/intl/en/common.json +++ b/src/intl/en/common.json @@ -120,6 +120,11 @@ "feedback-widget-thank-you-subtitle-ext": "If you need help, you can reach out to the community on our Discord.", "feedback-widget-thank-you-timing": "2–3 min", "feedback-widget-thank-you-title": "Thank you for your feedback!", + "filter-bar-clear": "Clear filter", + "filter-bar-empty": "No items match the selected filter", + "filter-bar-no-results": "No results found", + "filter-bar-placeholder": "Filter by", + "filter-bar-showing": "Showing", "find-wallet": "Find wallet", "founders": "Founders", "from": "From", From 766f54e6134207bbeb45fe93cb2f0125dac07765 Mon Sep 17 00:00:00 2001 From: Paul Wackerow <54227730+wackerow@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:26:44 -0500 Subject: [PATCH 2/4] refactor(apps): migrate AppsTable to use FilterBar component - Replace Select dropdown with reusable FilterBar Co-Authored-By: Claude Opus 4.5 --- app/[locale]/apps/_components/AppsTable.tsx | 83 +++++---------------- 1 file changed, 20 insertions(+), 63 deletions(-) diff --git a/app/[locale]/apps/_components/AppsTable.tsx b/app/[locale]/apps/_components/AppsTable.tsx index 0746c3b2938..23ecdd542ea 100644 --- a/app/[locale]/apps/_components/AppsTable.tsx +++ b/app/[locale]/apps/_components/AppsTable.tsx @@ -4,23 +4,12 @@ import { useMemo, useState } from "react" import { AppData } from "@/lib/types" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" - -import { trackCustomEvent } from "@/lib/utils/matomo" +import FilterBar from "@/components/FilterBar" import AppCard from "./AppCard" -import useTranslation from "@/hooks/useTranslation" - const AppsTable = ({ apps }: { apps: AppData[] }) => { - const { t } = useTranslation("page-apps") - const [filterBy, setFilterBy] = useState("All") + const [filterBy, setFilterBy] = useState() const subCategories = useMemo( () => [...new Set(apps.flatMap((app) => app.subCategory))], @@ -34,63 +23,31 @@ const AppsTable = ({ apps }: { apps: AppData[] }) => { const filteredApps = useMemo( () => apps.filter((app) => { - if (filterBy === "All") return true + if (!filterBy) return true return app.subCategory.includes(filterBy) }), [apps, filterBy] ) + const filterItems = subCategories.map((subCategory) => ({ + value: subCategory, + label: `${subCategory} (${getSubCategoryCount(subCategory)})`, + })) + return (
-
-
-

{t("page-apps-filter-by")}

- -
-
-

- {t("page-apps-showing")}{" "} - - ( - {filteredApps.length === apps.length - ? apps.length - : `${filteredApps.length}/${apps.length}`} - ) - -

-
-
+
{filteredApps.map((app) => (
From 2e8f0092b0db96afc43994e20ad85c94550a446a Mon Sep 17 00:00:00 2001 From: Paul Wackerow <54227730+wackerow@users.noreply.github.com> Date: Tue, 27 Jan 2026 16:16:33 -0500 Subject: [PATCH 3/4] refactor(developers/apps): migrate category pages to FilterBar - Create CategoryAppsGrid client component using shared FilterBar - Move filtering state from URL params to client-side useState - Delete TagFilter component (replaced by FilterBar) - Simplify page.tsx by extracting grid logic to dedicated component Co-Authored-By: Claude --- .../developers/apps/[category]/page.tsx | 102 ++------------ .../apps/_components/CategoryAppsGrid.tsx | 84 ++++++++++++ .../developers/apps/_components/TagFilter.tsx | 125 ------------------ src/components/FilterBar/index.tsx | 4 +- 4 files changed, 98 insertions(+), 217 deletions(-) create mode 100644 app/[locale]/developers/apps/_components/CategoryAppsGrid.tsx delete mode 100644 app/[locale]/developers/apps/_components/TagFilter.tsx diff --git a/app/[locale]/developers/apps/[category]/page.tsx b/app/[locale]/developers/apps/[category]/page.tsx index 4dd8f8a2510..3f0a15b501d 100644 --- a/app/[locale]/developers/apps/[category]/page.tsx +++ b/app/[locale]/developers/apps/[category]/page.tsx @@ -1,5 +1,3 @@ -import { AppWindowMac } from "lucide-react" -import Image from "next/image" import { redirect } from "next/navigation" import { getTranslations, setRequestLocale } from "next-intl/server" @@ -8,24 +6,22 @@ import type { CommitHistory, Lang, PageParams } from "@/lib/types" import { ContentHero } from "@/components/Hero" import MainArticle from "@/components/MainArticle" import SubpageCard from "@/components/SubpageCard" -import { LinkBox, LinkOverlay } from "@/components/ui/link-box" import { Section } from "@/components/ui/section" -import { TagsInlineText } from "@/components/ui/tag" import { getAppPageContributorInfo } from "@/lib/utils/contributors" import { getMetadata } from "@/lib/utils/metadata" import AppModalContents from "../_components/AppModalContents" import AppModalWrapper from "../_components/AppModalWrapper" +import CategoryAppsGrid from "../_components/CategoryAppsGrid" import HighlightsSection from "../_components/HighlightsSection" -import TagFilter from "../_components/TagFilter" import { DEV_APP_CATEGORIES } from "../constants" -import type { DeveloperAppCategorySlug, DeveloperAppTag } from "../types" +import type { DeveloperAppCategorySlug } from "../types" import { getCachedHighlightsByCategory, getCategoryPageHighlights, + transformDeveloperAppsData, } from "../utils" -import { transformDeveloperAppsData } from "../utils" import DevelopersAppsCategoryJsonLD from "./page-jsonld" @@ -36,10 +32,10 @@ const Page = async ({ searchParams, }: { params: PageParams & { category: DeveloperAppCategorySlug } - searchParams: { appId?: string; tag?: string } + searchParams: { appId?: string } }) => { const { locale, category } = params - const { appId, tag } = searchParams + const { appId } = searchParams setRequestLocale(locale) const t = await getTranslations({ locale, namespace: "page-developers-apps" }) @@ -54,55 +50,22 @@ const Page = async ({ new Set(allCategoryData.flatMap((app) => app.tags)) ).sort() - // Filter by selected tag if present (validate it's a real tag) - const validTag = - tag && uniqueTags.includes(tag as DeveloperAppTag) - ? (tag as DeveloperAppTag) - : undefined - const categoryData = validTag - ? allCategoryData.filter((app) => app.tags.includes(validTag)) - : allCategoryData - const activeApp = enrichedData.find((app) => app.id === appId) - // Clean up invalid searchParams by redirecting - const hasInvalidTag = tag && !validTag - const hasInvalidAppId = appId && !activeApp - if (hasInvalidTag || hasInvalidAppId) { - const params = new URLSearchParams() - if (validTag) params.set("tag", validTag) - if (activeApp) params.set("appId", activeApp.id) - const queryString = params.toString() - redirect( - `/developers/apps/${category}${queryString ? `?${queryString}` : ""}` - ) + // Clean up invalid appId by redirecting + if (appId && !activeApp) { + redirect(`/developers/apps/${category}`) } - // Prepare translations for client component + // Prepare tag labels for client component const tagLabels = Object.fromEntries( uniqueTags.map((tag) => [tag, t(`page-developers-apps-tag-${tag}`)]) ) - const filterLabels = { - filterBy: t("page-developers-apps-filter-label"), - clearFilter: t("page-developers-apps-filter-clear"), - noTags: t("page-developers-apps-filter-no-tags"), - showing: t("page-developers-apps-filter-showing"), - } // Get dynamic highlights based on stars and recent activity (cached weekly) const highlightsByCategory = await getCachedHighlightsByCategory(enrichedData) const highlights = getCategoryPageHighlights(highlightsByCategory, category) - // Helper to build app modal link with preserved tag param - const buildAppLink = (appId: string) => { - const params = new URLSearchParams() - params.set("appId", appId) - if (validTag) { - params.set("tag", validTag) - } - return `?${params.toString()}` - } - // Get contributor info for JSON-LD const commitHistoryCache: CommitHistory = {} const { contributors } = await getAppPageContributorInfo( @@ -135,52 +98,11 @@ const Page = async ({ {t("page-developers-apps-applications-title")} - - -
- {categoryData.map((app) => ( - - -
- {app.thumbnail_url ? ( - - ) : ( - - )} -
-
-

{app.name}

- - t(`page-developers-apps-tag-${tag}`) - )} - variant="light" - className="lowercase" - /> -
-
-
- ))} -
diff --git a/app/[locale]/developers/apps/_components/CategoryAppsGrid.tsx b/app/[locale]/developers/apps/_components/CategoryAppsGrid.tsx new file mode 100644 index 00000000000..968dc65b39e --- /dev/null +++ b/app/[locale]/developers/apps/_components/CategoryAppsGrid.tsx @@ -0,0 +1,84 @@ +"use client" + +import { useMemo, useState } from "react" +import { AppWindowMac } from "lucide-react" +import Image from "next/image" + +import FilterBar from "@/components/FilterBar" +import { LinkBox, LinkOverlay } from "@/components/ui/link-box" +import { TagsInlineText } from "@/components/ui/tag" + +import type { DeveloperApp } from "../types" + +type CategoryAppsGridProps = { + apps: DeveloperApp[] + uniqueTags: string[] + tagLabels: Record +} + +export default function CategoryAppsGrid({ + apps, + uniqueTags, + tagLabels, +}: CategoryAppsGridProps) { + const [selectedTag, setSelectedTag] = useState() + + const filteredApps = useMemo( + () => + selectedTag ? apps.filter((app) => app.tags.includes(selectedTag)) : apps, + [apps, selectedTag] + ) + + const filterItems = uniqueTags.map((tag) => ({ + value: tag, + label: tagLabels[tag], + })) + + return ( + <> + + +
+ {filteredApps.map((app) => ( + + +
+ {app.thumbnail_url ? ( + + ) : ( + + )} +
+
+

{app.name}

+ tagLabels[tag])} + variant="light" + className="lowercase" + /> +
+
+
+ ))} +
+ + ) +} diff --git a/app/[locale]/developers/apps/_components/TagFilter.tsx b/app/[locale]/developers/apps/_components/TagFilter.tsx deleted file mode 100644 index ba63e75cfe1..00000000000 --- a/app/[locale]/developers/apps/_components/TagFilter.tsx +++ /dev/null @@ -1,125 +0,0 @@ -"use client" - -import { useState } from "react" -import { ChevronDown, X } from "lucide-react" -import { useRouter, useSearchParams } from "next/navigation" - -import { Button } from "@/components/ui/buttons/Button" -import { - Command, - CommandEmpty, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command" -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover" - -type TagFilterProps = { - tags: string[] - tagLabels: Record - selectedTag?: string - category: string - count: number - labels: { - filterBy: string - clearFilter: string - noTags: string - showing: string - } -} - -export default function TagFilter({ - tags, - tagLabels, - selectedTag, - category, - count, - labels, -}: TagFilterProps) { - const router = useRouter() - const searchParams = useSearchParams() - const [open, setOpen] = useState(false) - - const handleSelectTag = (tag: string) => { - const params = new URLSearchParams(searchParams.toString()) - params.set("tag", tag) - router.push(`/developers/apps/${category}?${params.toString()}`, { - scroll: false, - }) - setOpen(false) - } - - const handleClearTag = () => { - const params = new URLSearchParams(searchParams.toString()) - params.delete("tag") - const queryString = params.toString() - router.push( - `/developers/apps/${category}${queryString ? `?${queryString}` : ""}`, - { scroll: false } - ) - } - - const COMBOBOX_ID = "tag-filter-listbox" - - return ( -
-
- - - - - - - - - {labels.noTags} - {tags.map((tag) => ( - handleSelectTag(tag)} - > - {tagLabels[tag]} - - ))} - - - - - - {selectedTag && ( - - )} -
- -

- {labels.showing} ({count}) -

-
- ) -} diff --git a/src/components/FilterBar/index.tsx b/src/components/FilterBar/index.tsx index a6b6751af66..02963b97f4b 100644 --- a/src/components/FilterBar/index.tsx +++ b/src/components/FilterBar/index.tsx @@ -128,8 +128,8 @@ export default function FilterBar({ {value && (