Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 33 additions & 10 deletions apps/web/src/components/Ecosystem/Card.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,48 @@
'use client';

/**
* Ecosystem Card Component
*
* Displays an individual ecosystem project/app in a card format.
* Includes project logo, name, description, and category tags.
*/

import ImageWithLoading from 'apps/web/src/components/ImageWithLoading';
import Card from 'apps/web/src/components/base-org/Card';
import Text from 'apps/web/src/components/base-org/typography/TextRedesign';
import { TextVariant } from 'apps/web/src/components/base-org/typography/TextRedesign/types';
import Title from 'apps/web/src/components/base-org/typography/TitleRedesign';
import { TitleLevel } from 'apps/web/src/components/base-org/typography/TitleRedesign/types';
import type { EcosystemApp } from 'apps/web/src/types/ecosystem';

type Props = {
name: string;
url: string;
description: string;
imageUrl: string;
category: string;
subcategory: string;
};
type Props = EcosystemApp;

const MAX_DESCRIPTION_LENGTH = 200;

function getNiceDomainDisplayFromUrl(url: string) {
/**
* Extracts a clean domain name from a URL for display purposes.
* Removes protocol and www prefix, and strips path segments.
*
* @param url - The full URL to extract the domain from
* @returns The cleaned domain name (e.g., "example.com")
*
* @example
* getNiceDomainDisplayFromUrl("https://www.example.com/path") // returns "example.com"
*/
function getNiceDomainDisplayFromUrl(url: string): string {
return url.replace('https://', '').replace('http://', '').replace('www.', '').split('/')[0];
}

/**
* EcosystemCard displays a single ecosystem project in a clickable card format.
*
* Features:
* - Project logo with loading state
* - Project name and domain
* - Truncated description (max 200 characters)
* - Category and subcategory tags
* - Opens project URL in a new tab
*/
export default function EcosystemCard({
name,
url,
Expand All @@ -39,7 +61,8 @@ export default function EcosystemCard({
href={url}
rel="noreferrer noopener"
target="_blank"
className="flex flex-col items-stretch w-full h-full justify-stretch"
aria-label={`Visit ${name} - ${category} project. ${truncatedDescription}`}
className="flex flex-col items-stretch w-full h-full justify-stretch focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 rounded-lg"
>
<Card
wrapperClassName="w-full h-full"
Expand Down
23 changes: 14 additions & 9 deletions apps/web/src/components/Ecosystem/Content.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,28 @@
'use client';

/**
* Ecosystem Content Component
*
* Main content component for the Base Ecosystem page.
* Handles filtering, searching, and displaying ecosystem apps.
*/

import ecosystemApps from 'apps/web/src/data/ecosystem.json';
import { SearchBar } from 'apps/web/src/components/Ecosystem/SearchBar';
import { useMemo, useState, useEffect, useCallback } from 'react';
import { List } from 'apps/web/src/components/Ecosystem/List';
import { useSearchParams } from 'next/navigation';
import { EcosystemFilters } from 'apps/web/src/components/Ecosystem/EcosystemFilters';
import EcosystemFiltersMobile from 'apps/web/src/components/Ecosystem/EcosystemFiltersMobile';
import type { DecoratedEcosystemApp, EcosystemCategoryConfig } from 'apps/web/src/types/ecosystem';

export type EcosystemApp = {
searchName: string;
name: string;
category: string;
subcategory: string;
url: string;
description: string;
imageUrl: string;
};
// Re-export for backward compatibility with existing imports
export type EcosystemApp = DecoratedEcosystemApp;

/**
* Category configuration mapping each category to its valid subcategories.
* Used for filtering ecosystem apps by category and subcategory.
*/
const config: Record<string, string[]> = {
ai: ['ai'],
wallet: ['account abstraction', 'multisig', 'self-custody'],
Expand Down
51 changes: 41 additions & 10 deletions apps/web/src/components/Ecosystem/List.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
'use client';

/**
* Ecosystem List Component
*
* Renders a grid of ecosystem app cards with animation support.
* Handles empty states and "load more" pagination.
*/

import classNames from 'classnames';
import { AnimatePresence, motion, cubicBezier } from 'motion/react';
import EcosystemCard from './Card';
import { EcosystemApp } from 'apps/web/src/components/Ecosystem/Content';
import type { DecoratedEcosystemApp } from 'apps/web/src/types/ecosystem';
import { Dispatch, SetStateAction, useCallback } from 'react';
import {
Button,
Expand All @@ -23,7 +31,10 @@ const cardAnimations = {
exit: { opacity: 0 },
};

function AnimatedEcosystemCard({ app }: { app: EcosystemApp }) {
/**
* Animated wrapper for ecosystem cards with enter/exit transitions.
*/
function AnimatedEcosystemCard({ app }: { app: DecoratedEcosystemApp }) {
return (
<motion.div
layout
Expand All @@ -38,21 +49,41 @@ function AnimatedEcosystemCard({ app }: { app: EcosystemApp }) {
);
}

/**
* Props for the List component
*/
interface ListProps {
/** Currently selected category filters */
selectedCategories: string[];
/** Current search query text */
searchText: string;
/** Filtered array of ecosystem apps to display */
apps: DecoratedEcosystemApp[];
/** Number of apps currently shown (for pagination) */
showCount: number;
/** Callback to update the show count */
setShowCount: Dispatch<SetStateAction<number>>;
/** Callback to clear the search query */
onClearSearch: () => void;
}

/**
* List component displays a paginated grid of ecosystem app cards.
*
* Features:
* - Animated card grid with staggered transitions
* - Empty state with helpful messaging
* - "View more" pagination button
* - Responsive grid layout (1-3 columns)
*/
export function List({
selectedCategories,
searchText,
apps,
showCount,
setShowCount,
onClearSearch,
}: {
selectedCategories: string[];
searchText: string;
apps: EcosystemApp[];
showCount: number;
setShowCount: Dispatch<SetStateAction<number>>;
onClearSearch: () => void;
}) {
}: ListProps) {
const canShowMore = showCount < apps.length;
const showEmptyState = apps.length === 0;
const truncatedApps = apps.slice(0, showCount);
Expand Down
48 changes: 41 additions & 7 deletions apps/web/src/components/Ecosystem/SearchBar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
'use client';

/**
* Ecosystem SearchBar Component
*
* An animated, accessible search input for filtering ecosystem apps.
* Features expandable mobile design and keyboard accessibility.
*/

import { Dispatch, SetStateAction, useCallback, useRef, useState, useEffect } from 'react';
import { motion, AnimatePresence, cubicBezier, Variants } from 'motion/react';

Expand All @@ -22,6 +30,9 @@ const buttonVariants: Variants = {
exit: { opacity: 0 },
};

/**
* Search icon SVG component
*/
function SearchIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="14" viewBox="0 0 15 14" fill="none">
Expand All @@ -33,6 +44,9 @@ function SearchIcon() {
);
}

/**
* Close/clear icon SVG component
*/
function XIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
Expand All @@ -44,13 +58,33 @@ function XIcon() {
);
}

export function SearchBar({
search,
setSearch,
}: {
/**
* Props for the SearchBar component
*/
interface SearchBarProps {
/** Current search query value */
search: string;
/** Callback to update the search query */
setSearch: Dispatch<SetStateAction<string>>;
}) {
}

/**
* SearchBar provides an animated, accessible search input for the ecosystem page.
*
* Features:
* - Expandable design on mobile devices
* - Animated transitions for expand/collapse
* - Clear button when search has content
* - Keyboard accessible with proper focus management
* - ARIA labels for screen reader support
*
* @example
* ```tsx
* const [search, setSearch] = useState('');
* <SearchBar search={search} setSearch={setSearch} />
* ```
*/
export function SearchBar({ search, setSearch }: SearchBarProps) {
const [isExpanded, setIsExpanded] = useState(false);
const debounced = useRef<number>();
const inputRef = useRef<HTMLInputElement>(null);
Expand Down Expand Up @@ -138,8 +172,8 @@ export function SearchBar({
<motion.button
type="button"
onClick={clearInput}
aria-label="clear input"
className="absolute right-2 flex-shrink-0"
aria-label="Clear search input"
className="absolute right-2 flex-shrink-0 rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
variants={buttonVariants}
initial="initial"
animate="animate"
Expand Down
125 changes: 125 additions & 0 deletions apps/web/src/types/ecosystem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* Ecosystem Types
*
* Type definitions for the Base Ecosystem page components and data structures.
* These types ensure type safety across ecosystem-related components.
*/

/**
* Valid ecosystem categories
* Each category groups related projects in the Base ecosystem
*/
export type EcosystemCategory = 'ai' | 'consumer' | 'defi' | 'infra' | 'onramp' | 'wallet';

/**
* Subcategories for AI projects
*/
export type AISubcategory = 'ai';

/**
* Subcategories for Consumer projects
*/
export type ConsumerSubcategory =
| 'creator'
| 'crypto taxes'
| 'dao'
| 'gaming'
| 'messaging'
| 'music'
| 'nft'
| 'payments'
| 'real world'
| 'social';

/**
* Subcategories for DeFi projects
*/
export type DeFiSubcategory =
| 'dex'
| 'dex aggregator'
| 'insurance'
| 'lending/borrowing'
| 'liquidity management'
| 'portfolio'
| 'stablecoin'
| 'yield vault';

/**
* Subcategories for Infrastructure projects
*/
export type InfraSubcategory =
| 'bridge'
| 'data'
| 'depin'
| 'developer tool'
| 'identity'
| 'node provider'
| 'raas'
| 'security';

/**
* Subcategories for Onramp projects
*/
export type OnrampSubcategory = 'centralized exchange' | 'fiat on-ramp';

/**
* Subcategories for Wallet projects
*/
export type WalletSubcategory = 'account abstraction' | 'multisig' | 'self-custody';

/**
* Union type of all valid subcategories
*/
export type EcosystemSubcategory =
| AISubcategory
| ConsumerSubcategory
| DeFiSubcategory
| InfraSubcategory
| OnrampSubcategory
| WalletSubcategory;

/**
* Represents a project/app in the Base ecosystem
*/
export interface EcosystemApp {
/** Display name of the project */
name: string;
/** Brief description of the project (max 200 characters) */
description: string;
/** Project website URL */
url: string;
/** Path to the project's logo image (e.g., '/images/partners/logo.png') */
imageUrl: string;
/** Primary category of the project */
category: EcosystemCategory;
/** Subcategory within the primary category */
subcategory: EcosystemSubcategory;
}

/**
* Extended ecosystem app with search-optimized fields
* Used internally for filtering and searching
*/
export interface DecoratedEcosystemApp extends EcosystemApp {
/** Lowercase version of name for case-insensitive search */
searchName: string;
}

/**
* Configuration mapping categories to their subcategories
*/
export type EcosystemCategoryConfig = Record<EcosystemCategory, EcosystemSubcategory[]>;

/**
* Props for ecosystem filter components
*/
export interface EcosystemFilterProps {
/** Currently selected categories */
selectedCategories: string[];
/** Currently selected subcategories */
selectedSubcategories: string[];
/** Callback to update selected subcategories */
setSelectedSubcategories: (subcategories: string[]) => void;
/** Category to subcategory configuration */
config: Record<string, string[]>;
}
Loading