From 853e81e8fb0a7acfac559477469559a5a9c02910 Mon Sep 17 00:00:00 2001 From: eagle Date: Fri, 21 Jun 2024 12:53:11 +0530 Subject: [PATCH] add explorer projects tab (#3524) * feat: add getPaginatedProjects query * feat: getPaginatedProjects to data layer * feat: add useProjets hook * feat: add projects route * feat: add projects tab in explorer home page * chore: rename project page and card to application page and card * fix: tests * feat: move CardSkeleton * chore: rm file * fix: query * feat: add projectPath * feat: add project card * feat: update explore projects page * feat: add getProjectsBySearchTerm to datalayer * feat: add useProjectsBySearchTerm hook * feat: search functionality * feat: update getProjects queries * fix: ui issues --- packages/common/src/routes/explorer.ts | 4 + packages/data-layer/src/data-layer.ts | 70 +++++ packages/data-layer/src/queries.ts | 80 +++++ ...Card.test.tsx => ApplicationCard.test.tsx} | 13 +- .../src/features/common/ApplicationCard.tsx | 104 +++++++ .../src/features/common/ProjectBanner.tsx | 37 +++ .../src/features/common/ProjectCard.tsx | 124 +------- .../discovery/ExploreApplicationsPage.tsx | 279 +++++++++++++++++ .../discovery/ExploreProjectsPage.tsx | 293 +++++------------- .../src/features/discovery/LandingTabs.tsx | 13 +- ...tsx => PaginatedApplicationsList.test.tsx} | 8 +- ...List.tsx => PaginatedApplicationsList.tsx} | 21 +- .../features/discovery/hooks/useProjects.ts | 39 +++ .../src/features/projects/ViewProject.tsx | 2 +- .../round/ViewCartPage/ViewCartPage.tsx | 2 +- .../src/features/round/ViewRoundPage.tsx | 3 +- packages/grant-explorer/src/index.tsx | 13 +- 17 files changed, 736 insertions(+), 369 deletions(-) rename packages/grant-explorer/src/features/common/{ProjectCard.test.tsx => ApplicationCard.test.tsx} (91%) create mode 100644 packages/grant-explorer/src/features/common/ApplicationCard.tsx create mode 100644 packages/grant-explorer/src/features/discovery/ExploreApplicationsPage.tsx rename packages/grant-explorer/src/features/discovery/{PaginatedProjectsList.test.tsx => PaginatedApplicationsList.test.tsx} (92%) rename packages/grant-explorer/src/features/discovery/{PaginatedProjectsList.tsx => PaginatedApplicationsList.tsx} (83%) create mode 100644 packages/grant-explorer/src/features/discovery/hooks/useProjects.ts diff --git a/packages/common/src/routes/explorer.ts b/packages/common/src/routes/explorer.ts index 80ec71fe37..cc2b667f67 100644 --- a/packages/common/src/routes/explorer.ts +++ b/packages/common/src/routes/explorer.ts @@ -9,3 +9,7 @@ export function applicationPath(p: { export function collectionPath(collectionCid: string): string { return `/#/collections/${collectionCid}`; } + +export function projectPath(p: { projectId: string }): string { + return `/#/projects/${p.projectId}`; +} diff --git a/packages/data-layer/src/data-layer.ts b/packages/data-layer/src/data-layer.ts index ba9841fe87..108e317def 100644 --- a/packages/data-layer/src/data-layer.ts +++ b/packages/data-layer/src/data-layer.ts @@ -53,6 +53,8 @@ import { getApplicationsForExplorer, getPayoutsByChainIdRoundIdProjectId, getApprovedApplicationsByProjectIds, + getPaginatedProjects, + getProjectsBySearchTerm, } from "./queries"; import { mergeCanonicalAndLinkedProjects } from "./utils"; @@ -313,6 +315,74 @@ export class DataLayer { return projects; } + /** + * Gets all active projects in the given range. + * @param first // number of projects to return + * @param offset // number of projects to skip + * + * @returns v2Project[] + */ + async getPaginatedProjects({ + first, + offset, + }: { + first: number; + offset: number; + }): Promise { + const requestVariables = { + first, + offset, + }; + + const response: { projects: v2Project[] } = await request( + this.gsIndexerEndpoint, + getPaginatedProjects, + requestVariables, + ); + + const projects: v2Project[] = mergeCanonicalAndLinkedProjects( + response.projects, + ); + + return projects; + } + + /** + * Gets all projects that match the search term. + * @param searchTerm // search term to filter projects + * @param first // number of projects to return + * @param offset // number of projects to skip + * + * @returns v2Project[] + */ + async getProjectsBySearchTerm({ + searchTerm, + first, + offset, + }: { + searchTerm: string; + first: number; + offset: number; + }): Promise { + const requestVariables = { + searchTerm, + first, + offset, + }; + + const response: { searchProjects: v2Project[] } = await request( + this.gsIndexerEndpoint, + getProjectsBySearchTerm, + requestVariables, + ); + + const projects: v2Project[] = mergeCanonicalAndLinkedProjects( + response.searchProjects, + ); + + return projects; + } + /** * getApplicationsByProjectIds() returns a list of projects by address. * @param projectIds diff --git a/packages/data-layer/src/queries.ts b/packages/data-layer/src/queries.ts index 3127fa34a8..3deb842a96 100644 --- a/packages/data-layer/src/queries.ts +++ b/packages/data-layer/src/queries.ts @@ -424,6 +424,86 @@ export const getProjectsByAddress = gql` } `; +/** + * Get active projects in the given range +`* @param $first - The number of projects to return + * @param $offset - The offset of the projects + * + * @returns The v2Projects + */ +export const getPaginatedProjects = gql` + query getPaginatedProjects($first: Int!, $offset: Int!) { + projects( + filter: { + tags: { equalTo: "allo-v2" } + not: { tags: { contains: "program" } } + chainId: { + in: [1, 137, 10, 324, 42161, 42220, 43114, 534352, 8453, 1329] + } + rounds: { every: { applicationsExist: true } } + } + first: $first + offset: $offset + ) { + id + chainId + metadata + metadataCid + name + nodeId + projectNumber + registryAddress + tags + nonce + anchorAddress + projectType + } + } +`; + +/** + * Get projects by search term + * @param $searchTerm - The search term +`* @param $first - The number of projects to return + * @param $offset - The offset of the projects + * + * @returns The v2Projects + */ +export const getProjectsBySearchTerm = gql` + query getProjectsBySearchTerm( + $searchTerm: String! + $first: Int! + $offset: Int! + ) { + searchProjects( + searchTerm: $searchTerm + filter: { + tags: { equalTo: "allo-v2" } + not: { tags: { contains: "program" } } + chainId: { + in: [1, 137, 10, 324, 42161, 42220, 43114, 534352, 8453, 1329] + } + rounds: { every: { applicationsExist: true } } + } + first: $first + offset: $offset + ) { + id + chainId + metadata + metadataCid + name + nodeId + projectNumber + registryAddress + tags + nonce + anchorAddress + projectType + } + } +`; + export const getProjectsAndRolesByAddress = gql` query getProjectsAndRolesByAddressQuery( $address: String! diff --git a/packages/grant-explorer/src/features/common/ProjectCard.test.tsx b/packages/grant-explorer/src/features/common/ApplicationCard.test.tsx similarity index 91% rename from packages/grant-explorer/src/features/common/ProjectCard.test.tsx rename to packages/grant-explorer/src/features/common/ApplicationCard.test.tsx index 4e9a19164a..ea4aa08167 100644 --- a/packages/grant-explorer/src/features/common/ProjectCard.test.tsx +++ b/packages/grant-explorer/src/features/common/ApplicationCard.test.tsx @@ -1,9 +1,10 @@ import { vi } from "vitest"; import { render, fireEvent, screen } from "@testing-library/react"; -import { ProjectCard, ProjectCardSkeleton } from "./ProjectCard"; +import { ApplicationCard } from "./ApplicationCard"; import { ApplicationSummary } from "data-layer"; import { zeroAddress } from "viem"; import { ChakraProvider } from "@chakra-ui/react"; +import { CardSkeleton } from "./ProjectBanner"; vi.mock("common/src/config", async () => { return { @@ -13,7 +14,7 @@ vi.mock("common/src/config", async () => { }; }); -describe("ProjectCard", () => { +describe("ApplicationCard", () => { const mockApplication: ApplicationSummary = { roundName: "This is a round name!", applicationRef: "1", @@ -37,7 +38,7 @@ describe("ProjectCard", () => { const removeFromCart = vi.fn(); render( - { const removeFromCart = vi.fn(); render( - { const removeFromCart = vi.fn(); render( - { it("renders ProjectCardSkeleton correctly", () => { render( - + ); }); diff --git a/packages/grant-explorer/src/features/common/ApplicationCard.tsx b/packages/grant-explorer/src/features/common/ApplicationCard.tsx new file mode 100644 index 0000000000..1624b916bb --- /dev/null +++ b/packages/grant-explorer/src/features/common/ApplicationCard.tsx @@ -0,0 +1,104 @@ +import { ReactComponent as CartCircleIcon } from "../../assets/icons/cart-circle.svg"; +import { ReactComponent as CheckedCircleIcon } from "../../assets/icons/checked-circle.svg"; +import { ApplicationSummary } from "data-layer"; +import { + Badge, + BasicCard, + CardContent, + CardDescription, + CardHeader, +} from "./styles"; +import { applicationPath } from "common/src/routes/explorer"; +import { ProjectBanner, ProjectLogo } from "./ProjectBanner"; +import { usePostHog } from "posthog-js/react"; + +export function ApplicationCard(props: { + application: ApplicationSummary; + inCart: boolean; + onAddToCart: (app: ApplicationSummary) => void; + onRemoveFromCart: (app: ApplicationSummary) => void; +}): JSX.Element { + const { + application, + inCart, + onAddToCart: addToCart, + onRemoveFromCart: removeFromCart, + } = props; + + const posthog = usePostHog(); + const roundId = application.roundId.toLowerCase(); + + return ( + + + + + + + {application.logoImageCid !== null && ( + + )} +
{application.name}
+ +
+ {application.summaryText} +
+
+ + + {application.roundName} + +
+
+
+
+ {inCart ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/packages/grant-explorer/src/features/common/ProjectBanner.tsx b/packages/grant-explorer/src/features/common/ProjectBanner.tsx index 180d68bf59..4667beb4be 100644 --- a/packages/grant-explorer/src/features/common/ProjectBanner.tsx +++ b/packages/grant-explorer/src/features/common/ProjectBanner.tsx @@ -1,3 +1,4 @@ +import { Skeleton, SkeletonCircle, SkeletonText } from "@chakra-ui/react"; import DefaultBannerImage from "../../assets/default_banner.jpg"; import { createIpfsImageUrl } from "common/src/ipfs"; import { getConfig } from "common/src/config"; @@ -31,3 +32,39 @@ export function ProjectBanner(props: { ); } + +export function ProjectLogo(props: { + className?: string; + imageCid: string; + size: number; +}): JSX.Element { + const { + ipfs: { baseUrl: ipfsBaseUrl }, + } = getConfig(); + + const projectLogoImageUrl = createIpfsImageUrl({ + baseUrl: ipfsBaseUrl, + cid: props.imageCid, + height: props.size * 2, + }); + + return ( + Project Banner + ); +} + +export function CardSkeleton(): JSX.Element { + return ( +
+ + + + +
+ ); +} diff --git a/packages/grant-explorer/src/features/common/ProjectCard.tsx b/packages/grant-explorer/src/features/common/ProjectCard.tsx index 7b4356bca7..87d6255b6a 100644 --- a/packages/grant-explorer/src/features/common/ProjectCard.tsx +++ b/packages/grant-explorer/src/features/common/ProjectCard.tsx @@ -1,86 +1,23 @@ -import { Skeleton, SkeletonCircle, SkeletonText } from "@chakra-ui/react"; -import { ReactComponent as CartCircleIcon } from "../../assets/icons/cart-circle.svg"; -import { ReactComponent as CheckedCircleIcon } from "../../assets/icons/checked-circle.svg"; -import { ApplicationSummary } from "data-layer"; -import { - Badge, - BasicCard, - CardContent, - CardDescription, - CardHeader, -} from "./styles"; -import { applicationPath } from "common/src/routes/explorer"; -import { ProjectBanner } from "./ProjectBanner"; -import { createIpfsImageUrl } from "common/src/ipfs"; -import { getConfig } from "common/src/config"; -import { usePostHog } from "posthog-js/react"; +import { v2Project } from "data-layer"; +import { BasicCard, CardContent, CardDescription, CardHeader } from "./styles"; +import { projectPath } from "common/src/routes/explorer"; +import { ProjectBanner, ProjectLogo } from "./ProjectBanner"; -export function ProjectLogo(props: { - className?: string; - imageCid: string; - size: number; -}): JSX.Element { - const { - ipfs: { baseUrl: ipfsBaseUrl }, - } = getConfig(); - - const projectLogoImageUrl = createIpfsImageUrl({ - baseUrl: ipfsBaseUrl, - cid: props.imageCid, - height: props.size * 2, - }); - - return ( - Project Banner - ); -} - -export function ProjectCardSkeleton(): JSX.Element { - return ( -
- - - - -
- ); -} - -export function ProjectCard(props: { - application: ApplicationSummary; - inCart: boolean; - onAddToCart: (app: ApplicationSummary) => void; - onRemoveFromCart: (app: ApplicationSummary) => void; -}): JSX.Element { - const { - application, - inCart, - onAddToCart: addToCart, - onRemoveFromCart: removeFromCart, - } = props; - - const posthog = usePostHog(); - const roundId = application.roundId.toLowerCase(); +export function ProjectCard(props: { project: v2Project }): JSX.Element { + const { project } = props; return ( - {application.logoImageCid !== null && ( + {project.metadata.logoImg !== undefined && ( )} -
{application.name}
+
{project.name}
- {application.summaryText} + {project.metadata.description}
- - - {application.roundName} -
-
-
- {inCart ? ( - - ) : ( - - )} -
-
); } diff --git a/packages/grant-explorer/src/features/discovery/ExploreApplicationsPage.tsx b/packages/grant-explorer/src/features/discovery/ExploreApplicationsPage.tsx new file mode 100644 index 0000000000..ddcc439dcc --- /dev/null +++ b/packages/grant-explorer/src/features/discovery/ExploreApplicationsPage.tsx @@ -0,0 +1,279 @@ +import { useParams } from "react-router-dom"; +import { useSearchParams } from "react-router-dom"; +import { GradientLayout } from "../common/DefaultLayout"; +import LandingHero from "./LandingHero"; +import { LandingSection } from "./LandingSection"; +import { useCartStorage } from "../../store"; +import { CartProject } from "../api/types"; +import { useMemo, useState } from "react"; +import { ApplicationSummary } from "data-layer"; +import { + createApplicationFetchOptions, + useApplications, + Filter, +} from "./hooks/useApplications"; +import { PaginatedApplicationsList } from "./PaginatedApplicationsList"; +import { MagnifyingGlassIcon } from "@heroicons/react/20/solid"; +import { useCategory } from "../categories/hooks/useCategories"; +import { CollectionDetails } from "../collections/CollectionDetails"; +import { FilterDropdown, FilterDropdownOption } from "../common/FilterDropdown"; +import { getEnabledChains } from "../../app/chainConfig"; +import { useIpfsCollection } from "../collections/hooks/useCollections"; + +const FILTER_OPTIONS: FilterDropdownOption[] = [ + { + label: "Network", + children: getEnabledChains().map(({ id, name }) => ({ + label: `Projects on ${name}`, + value: { type: "chain", chainId: id }, + })), + allowMultiple: true, + }, +]; + +export function createCartProjectFromApplication( + application: ApplicationSummary +): CartProject { + return { + anchorAddress: application.anchorAddress, + projectRegistryId: application.projectId, + roundId: application.roundId, + chainId: application.chainId, + grantApplicationId: application.roundApplicationId, + recipient: application.payoutWalletAddress, + grantApplicationFormAnswers: [], + status: "APPROVED", + applicationIndex: Number(application.roundApplicationId), + projectMetadata: { + title: application.name, + description: application.summaryText, + bannerImg: application.bannerImageCid, + logoImg: application.logoImageCid, + } as CartProject["projectMetadata"], + amount: "", + }; +} + +function createCompositeRoundApplicationId(application: ApplicationSummary) { + return [ + application.chainId, + application.roundId.toLowerCase(), + application.roundApplicationId.toLowerCase(), + ].join(":"); +} + +function urlParamsToFilterList(urlParams: URLSearchParams): Filter[] { + const chainIds = urlParams.getAll("chainId"); + + return chainIds.map((chainId) => ({ + type: "chain", + chainId: Number(chainId), + })); +} + +function filterListToUrlParams(filters: Filter[]): URLSearchParams { + return filters.reduce((acc, filter) => { + if (filter.type === "chain") { + acc.append("chainId", String(filter.chainId)); + } + return acc; + }, new URLSearchParams()); +} + +export function ExploreApplicationsPage(): JSX.Element { + const [urlParams, setUrlParams] = useSearchParams(); + const { collectionCid } = useParams(); + const [filters, setFilters] = useState( + urlParamsToFilterList(urlParams) + ); + + const category = useCategory(urlParams.get("categoryId")); + const collection = useIpfsCollection(collectionCid); + + const [searchInput, setSearchInput] = useState(urlParams.get("q") ?? ""); + const [searchQuery, setSearchQuery] = useState(urlParams.get("q") ?? ""); + + const isPreloading = category.isLoading || collection.isLoading; + const preloadingError = category.error || collection.error; + + const applicationsFetchOptions = + isPreloading || preloadingError + ? null + : createApplicationFetchOptions({ + searchQuery, + category: category.data, + collection: collection.data, + filters, + }); + + const { + applications, + applicationMeta, + totalApplicationsCount, + isLoading: applicationsLoading, + isLoadingMore, + loadNextPage, + hasMorePages, + error: applicationsError, + } = useApplications( + isPreloading || preloadingError ? null : applicationsFetchOptions + ); + + const isLoading = isPreloading || applicationsLoading; + const error = preloadingError || applicationsError; + + const allSemantic = applicationMeta.every( + (item) => item.searchType === "semantic" + ); + + const { projects, add, remove } = useCartStorage(); + + const applicationIdsInCart = useMemo(() => { + return new Set( + projects.map((project) => + [project.chainId, project.roundId, project.grantApplicationId].join(":") + ) + ); + }, [projects]); + + function addApplicationToCart(application: ApplicationSummary) { + const cartProject = createCartProjectFromApplication(application); + add(cartProject); + } + + function removeApplicationFromCart(application: ApplicationSummary) { + const cartProject = createCartProjectFromApplication(application); + remove(cartProject); + } + + function applicationExistsInCart(application: ApplicationSummary) { + return applicationIdsInCart.has( + createCompositeRoundApplicationId(application) + ); + } + + let pageTitle = "All projects"; + + if (searchQuery.length > 0) { + pageTitle = "Search results"; + } else if (category.data !== undefined) { + pageTitle = category.data.name; + } else if (collection.data !== undefined) { + pageTitle = collection.data.name ?? "Collection"; + } + + const onQueryChange: React.ChangeEventHandler = (e) => { + const q = e.target.value; + setSearchInput(q); + }; + + function onSearchSubmit(e: React.FormEvent) { + e.preventDefault(); + setSearchQuery(searchInput); + setUrlParams(`?q=${searchInput}`); + } + + function onFiltersChange(newFilters: Filter[]) { + setFilters(newFilters); + setUrlParams(`?${filterListToUrlParams(newFilters).toString()}`); + } + + return ( + + + + {collection.data && ( + + applications.forEach(addApplicationToCart) + } + /> + )} + + +
+ + + + {searchQuery.length === 0 && ( +
+ Filter by + + onChange={onFiltersChange} + selected={filters} + options={FILTER_OPTIONS} + /> +
+ )} + + ) + } + > + {error !== undefined && ( +
Something went wrong
+ )} + + {isLoading === false && + isLoadingMore === false && + applications.length === 0 && + collection === undefined && ( +

+ Your search did not match any projects. Try again using different + keywords. +

+ )} + {isLoading === false && + applicationMeta.length > 0 && + allSemantic && + !category && ( +

+ Your search did not match any projects. Try again or feel free to + browse through projects similar to your search. +

+ )} + {error === undefined && ( +
+ +
+ )} +
+
+ ); +} diff --git a/packages/grant-explorer/src/features/discovery/ExploreProjectsPage.tsx b/packages/grant-explorer/src/features/discovery/ExploreProjectsPage.tsx index d8387102f6..8b61862698 100644 --- a/packages/grant-explorer/src/features/discovery/ExploreProjectsPage.tsx +++ b/packages/grant-explorer/src/features/discovery/ExploreProjectsPage.tsx @@ -1,167 +1,44 @@ -import { useParams } from "react-router-dom"; -import { useSearchParams } from "react-router-dom"; import { GradientLayout } from "../common/DefaultLayout"; import LandingHero from "./LandingHero"; +import { useDataLayer, v2Project } from "data-layer"; +import { ProjectCard } from "../common/ProjectCard"; +import { LoadingRing } from "../common/Spinner"; +import { useProjects, useProjectsBySearchTerm } from "./hooks/useProjects"; import { LandingSection } from "./LandingSection"; -import { useCartStorage } from "../../store"; -import { CartProject } from "../api/types"; -import { useMemo, useState } from "react"; -import { ApplicationSummary } from "data-layer"; -import { - createApplicationFetchOptions, - useApplications, - Filter, -} from "./hooks/useApplications"; -import { PaginatedProjectsList } from "./PaginatedProjectsList"; -import { MagnifyingGlassIcon } from "@heroicons/react/20/solid"; -import { useCategory } from "../categories/hooks/useCategories"; -import { CollectionDetails } from "../collections/CollectionDetails"; -import { FilterDropdown, FilterDropdownOption } from "../common/FilterDropdown"; -import { getEnabledChains } from "../../app/chainConfig"; -import { useIpfsCollection } from "../collections/hooks/useCollections"; - -const FILTER_OPTIONS: FilterDropdownOption[] = [ - { - label: "Network", - children: getEnabledChains().map(({ id, name }) => ({ - label: `Projects on ${name}`, - value: { type: "chain", chainId: id }, - })), - allowMultiple: true, - }, -]; - -export function createCartProjectFromApplication( - application: ApplicationSummary -): CartProject { - return { - anchorAddress: application.anchorAddress, - projectRegistryId: application.projectId, - roundId: application.roundId, - chainId: application.chainId, - grantApplicationId: application.roundApplicationId, - recipient: application.payoutWalletAddress, - grantApplicationFormAnswers: [], - status: "APPROVED", - applicationIndex: Number(application.roundApplicationId), - projectMetadata: { - title: application.name, - description: application.summaryText, - bannerImg: application.bannerImageCid, - logoImg: application.logoImageCid, - } as CartProject["projectMetadata"], - amount: "", - }; -} - -function createCompositeRoundApplicationId(application: ApplicationSummary) { - return [ - application.chainId, - application.roundId.toLowerCase(), - application.roundApplicationId.toLowerCase(), - ].join(":"); -} - -function urlParamsToFilterList(urlParams: URLSearchParams): Filter[] { - const chainIds = urlParams.getAll("chainId"); - - return chainIds.map((chainId) => ({ - type: "chain", - chainId: Number(chainId), - })); -} - -function filterListToUrlParams(filters: Filter[]): URLSearchParams { - return filters.reduce((acc, filter) => { - if (filter.type === "chain") { - acc.append("chainId", String(filter.chainId)); - } - return acc; - }, new URLSearchParams()); -} +import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; +import { useState } from "react"; export function ExploreProjectsPage(): JSX.Element { - const [urlParams, setUrlParams] = useSearchParams(); - const { collectionCid } = useParams(); - const [filters, setFilters] = useState( - urlParamsToFilterList(urlParams) - ); - - const category = useCategory(urlParams.get("categoryId")); - const collection = useIpfsCollection(collectionCid); - - const [searchInput, setSearchInput] = useState(urlParams.get("q") ?? ""); - const [searchQuery, setSearchQuery] = useState(urlParams.get("q") ?? ""); - - const isPreloading = category.isLoading || collection.isLoading; - const preloadingError = category.error || collection.error; + const dataLayer = useDataLayer(); - const applicationsFetchOptions = - isPreloading || preloadingError - ? null - : createApplicationFetchOptions({ - searchQuery, - category: category.data, - collection: collection.data, - filters, - }); + const [searchInput, setSearchInput] = useState(""); + const [searchQuery, setSearchQuery] = useState(""); const { - applications, - applicationMeta, - totalApplicationsCount, - isLoading: applicationsLoading, - isLoadingMore, - loadNextPage, - hasMorePages, - error: applicationsError, - } = useApplications( - isPreloading || preloadingError ? null : applicationsFetchOptions + data: projects, + isLoading, + error, + } = useProjects( + { + first: 100, + offset: 0, + }, + dataLayer ); - const isLoading = isPreloading || applicationsLoading; - const error = preloadingError || applicationsError; - - const allSemantic = applicationMeta.every( - (item) => item.searchType === "semantic" + const { + data: projectsFromSearch, + isLoading: searchLoading, + error: searchError, + } = useProjectsBySearchTerm( + { + searchTerm: searchQuery, + first: 10, + offset: 0, + }, + dataLayer ); - const { projects, add, remove } = useCartStorage(); - - const applicationIdsInCart = useMemo(() => { - return new Set( - projects.map((project) => - [project.chainId, project.roundId, project.grantApplicationId].join(":") - ) - ); - }, [projects]); - - function addApplicationToCart(application: ApplicationSummary) { - const cartProject = createCartProjectFromApplication(application); - add(cartProject); - } - - function removeApplicationFromCart(application: ApplicationSummary) { - const cartProject = createCartProjectFromApplication(application); - remove(cartProject); - } - - function applicationExistsInCart(application: ApplicationSummary) { - return applicationIdsInCart.has( - createCompositeRoundApplicationId(application) - ); - } - - let pageTitle = "All projects"; - - if (searchQuery.length > 0) { - pageTitle = "Search results"; - } else if (category.data !== undefined) { - pageTitle = category.data.name; - } else if (collection.data !== undefined) { - pageTitle = collection.data.name ?? "Collection"; - } - const onQueryChange: React.ChangeEventHandler = (e) => { const q = e.target.value; setSearchInput(q); @@ -170,38 +47,22 @@ export function ExploreProjectsPage(): JSX.Element { function onSearchSubmit(e: React.FormEvent) { e.preventDefault(); setSearchQuery(searchInput); - setUrlParams(`?q=${searchInput}`); - } - - function onFiltersChange(newFilters: Filter[]) { - setFilters(newFilters); - setUrlParams(`?${filterListToUrlParams(newFilters).toString()}`); } return ( - {collection.data && ( - - applications.forEach(addApplicationToCart) - } - /> - )} - 0 + ? "Search results" + : projects + ? "All projects" + : "Loading projects..." } action={ - collection && ( + projects && (
- {searchQuery.length === 0 && ( -
- Filter by - - onChange={onFiltersChange} - selected={filters} - options={FILTER_OPTIONS} - /> -
- )}
) } - > - {error !== undefined && ( -
Something went wrong
- )} + >
- {isLoading === false && - isLoadingMore === false && - applications.length === 0 && - collection === undefined && ( -

- Your search did not match any projects. Try again using different - keywords. -

- )} - {isLoading === false && - applicationMeta.length > 0 && - allSemantic && - !category && ( -

- Your search did not match any projects. Try again or feel free to - browse through projects similar to your search. -

- )} - {error === undefined && ( -
- -
- )} - + {(searchQuery.length > 0 + ? searchError !== undefined + : error !== undefined) && ( +
Something went wrong
+ )} + {(searchQuery.length > 0 + ? searchError === undefined + : error === undefined) && ( +
+ 0 ? projectsFromSearch : projects} + /> +
+ )} + {(searchQuery.length > 0 ? searchLoading : isLoading) && ( +
+ +
+ )}
); } + +export type PaginatedProjectsListProps = { + projects: v2Project[] | undefined; +}; + +export function PaginatedProjectsList({ + projects, +}: PaginatedProjectsListProps): JSX.Element { + return ( + <> + {projects && + projects.map((project) => ( + + ))} + + ); +} diff --git a/packages/grant-explorer/src/features/discovery/LandingTabs.tsx b/packages/grant-explorer/src/features/discovery/LandingTabs.tsx index 18b8cc1f1a..40a6379685 100644 --- a/packages/grant-explorer/src/features/discovery/LandingTabs.tsx +++ b/packages/grant-explorer/src/features/discovery/LandingTabs.tsx @@ -37,13 +37,12 @@ export default function LandingTabs() { children: isDesktop ? "Explore rounds" : "Rounds", tabName: "home-rounds-tab", }, - // Note: removing when a GG round is not running. - // { - // to: "/projects", - // activeRegExp: /^\/projects/, - // children: isDesktop ? "Explore projects" : "Projects", - // tabName: "home-projects-tab", - // }, + { + to: "/projects", + activeRegExp: /^\/projects/, + children: isDesktop ? "Explore projects" : "Projects", + tabName: "home-projects-tab", + }, ]; return ( diff --git a/packages/grant-explorer/src/features/discovery/PaginatedProjectsList.test.tsx b/packages/grant-explorer/src/features/discovery/PaginatedApplicationsList.test.tsx similarity index 92% rename from packages/grant-explorer/src/features/discovery/PaginatedProjectsList.test.tsx rename to packages/grant-explorer/src/features/discovery/PaginatedApplicationsList.test.tsx index 3ae1fbe574..ec7b9bca1d 100644 --- a/packages/grant-explorer/src/features/discovery/PaginatedProjectsList.test.tsx +++ b/packages/grant-explorer/src/features/discovery/PaginatedApplicationsList.test.tsx @@ -1,6 +1,6 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { vi } from "vitest"; -import { PaginatedProjectsList } from "./PaginatedProjectsList"; // Adjust the import path as needed +import { PaginatedApplicationsList } from "./PaginatedApplicationsList"; // Adjust the import path as needed import { ApplicationSummary } from "data-layer"; import { zeroAddress } from "viem"; @@ -54,10 +54,10 @@ const mockAddApplicationToCart = vi.fn(); const mockRemoveApplicationFromCart = vi.fn(); const mockApplicationExistsInCart = vi.fn(); -describe("PaginatedProjectsList", () => { +describe("PaginatedApplicationsList", () => { it("renders list of applications", () => { render( - { it("calls loadNextPage when Load more button is clicked", async () => { render( - boolean; } -export function PaginatedProjectsList({ +export function PaginatedApplicationsList({ applications, isLoading, isLoadingMore, @@ -24,13 +25,13 @@ export function PaginatedProjectsList({ onAddApplicationToCart, onRemoveApplicationFromCart, applicationExistsInCart, -}: PaginatedProjectsListProps): JSX.Element { +}: PaginatedApplicationsListProps): JSX.Element { const posthog = usePostHog(); return ( <> {applications.map((application) => ( - - - - - - + + + + + )} {!isLoading && hasMorePages && ( diff --git a/packages/grant-explorer/src/features/discovery/hooks/useProjects.ts b/packages/grant-explorer/src/features/discovery/hooks/useProjects.ts new file mode 100644 index 0000000000..c43c8f80b8 --- /dev/null +++ b/packages/grant-explorer/src/features/discovery/hooks/useProjects.ts @@ -0,0 +1,39 @@ +import useSWR from "swr"; +import { DataLayer } from "data-layer"; + +type Params = { + first: number; + offset: number; +}; + +export function useProjects(params: Params, dataLayer: DataLayer) { + return useSWR(["projects", params], async () => { + const validatedParams = { + first: params.first, + offset: params.offset, + }; + return (await dataLayer.getPaginatedProjects(validatedParams)) ?? undefined; + }); +} + +type searchParams = { + searchTerm: string; + first: number; + offset: number; +}; + +export function useProjectsBySearchTerm( + params: searchParams, + dataLayer: DataLayer +) { + return useSWR(["projects", params], async () => { + const validatedParams = { + searchTerm: params.searchTerm, + first: params.first, + offset: params.offset, + }; + return ( + (await dataLayer.getProjectsBySearchTerm(validatedParams)) ?? undefined + ); + }); +} diff --git a/packages/grant-explorer/src/features/projects/ViewProject.tsx b/packages/grant-explorer/src/features/projects/ViewProject.tsx index f0887ebbef..e77d776c5d 100644 --- a/packages/grant-explorer/src/features/projects/ViewProject.tsx +++ b/packages/grant-explorer/src/features/projects/ViewProject.tsx @@ -99,7 +99,7 @@ export default function ViewProject() { }, { name: "Projects", - path: `/`, + path: `/projects`, }, { name: project?.metadata.title, diff --git a/packages/grant-explorer/src/features/round/ViewCartPage/ViewCartPage.tsx b/packages/grant-explorer/src/features/round/ViewCartPage/ViewCartPage.tsx index bf16339670..b3a7950617 100644 --- a/packages/grant-explorer/src/features/round/ViewCartPage/ViewCartPage.tsx +++ b/packages/grant-explorer/src/features/round/ViewCartPage/ViewCartPage.tsx @@ -9,7 +9,7 @@ import { useCartStorage } from "../../../store"; import { CartWithProjects } from "./CartWithProjects"; import { SummaryContainer } from "./SummaryContainer"; import { useDataLayer } from "data-layer"; -import { createCartProjectFromApplication } from "../../discovery/ExploreProjectsPage"; +import { createCartProjectFromApplication } from "../../discovery/ExploreApplicationsPage"; export default function ViewCart() { const { projects, setCart } = useCartStorage(); diff --git a/packages/grant-explorer/src/features/round/ViewRoundPage.tsx b/packages/grant-explorer/src/features/round/ViewRoundPage.tsx index af52f9865d..91c7284f5f 100644 --- a/packages/grant-explorer/src/features/round/ViewRoundPage.tsx +++ b/packages/grant-explorer/src/features/round/ViewRoundPage.tsx @@ -36,7 +36,7 @@ import { getDaysLeft, isDirectRound, isInfiniteDate } from "../api/utils"; import { PassportWidget } from "../common/PassportWidget"; import NotFoundPage from "../common/NotFoundPage"; -import { ProjectBanner } from "../common/ProjectBanner"; +import { ProjectBanner, ProjectLogo } from "../common/ProjectBanner"; import RoundEndedBanner from "../common/RoundEndedBanner"; import { Spinner } from "../common/Spinner"; import { @@ -59,7 +59,6 @@ import { getAlloVersion } from "common/src/config"; import { ExclamationCircleIcon } from "@heroicons/react/24/solid"; import { DefaultLayout } from "../common/DefaultLayout"; import { getUnixTime } from "date-fns"; -import { ProjectLogo } from "../common/ProjectCard"; import { Application, useDataLayer } from "data-layer"; import { useRoundApprovedApplications } from "../projects/hooks/useRoundApplications"; import { diff --git a/packages/grant-explorer/src/index.tsx b/packages/grant-explorer/src/index.tsx index 92a068af6f..e1a27b007c 100644 --- a/packages/grant-explorer/src/index.tsx +++ b/packages/grant-explorer/src/index.tsx @@ -3,7 +3,7 @@ import "./browserPatches"; import { getConfig } from "common/src/config"; import { QueryClientProvider } from "@tanstack/react-query"; import { RainbowKitProvider } from "@rainbow-me/rainbowkit"; -import { ExploreProjectsPage } from "./features/discovery/ExploreProjectsPage"; +import { ExploreApplicationsPage } from "./features/discovery/ExploreApplicationsPage"; import { DataLayer, DataLayerProvider } from "data-layer"; import React from "react"; import ReactDOM from "react-dom/client"; @@ -34,6 +34,7 @@ import ViewRound from "./features/round/ViewRoundPage"; import AlloWrapper from "./features/api/AlloWrapper"; import { PostHogProvider } from "posthog-js/react"; import ViewProject from "./features/projects/ViewProject"; +import { ExploreProjectsPage } from "./features/discovery/ExploreProjectsPage"; initSentry(); initDatadog(); @@ -92,10 +93,10 @@ root.render( {/* Project Routes */} - {/* } - /> */} + } + /> } + element={} /> {/* 404 */}