diff --git a/src/app/pages/explore/explore.component.html b/src/app/pages/explore/explore.component.html index a2d3563..ad000ac 100644 --- a/src/app/pages/explore/explore.component.html +++ b/src/app/pages/explore/explore.component.html @@ -68,13 +68,62 @@ {{ showMobileFilters ? 'close' : 'tune' }} - + - + + +
+ + @if (showFundingRangeDropdown) { +
+

+ Funding Progress +

+ @for (range of fundingRangeOptions; track range.id) { + + } +
+ } +
+ +
} - + +
article {{ filteredProjects().length }} {{ filteredProjects().length === 1 ? 'project' : 'projects' }}
- - @if (searchTerm() || activeFilter() !== 'all' || activeSort() !== 'default') { + + + @if (hasActiveFilters()) { + @for (category of categoryOptions; track category.id) { + + } + + +
-

Filter by

+

Status

@for (filter of filterOptions; track filter) {
+ +
+

Funding Progress

+
+ @for (range of fundingRangeOptions; track range.id) { + + } +
+
+

Sort by

@@ -211,13 +344,14 @@

Sort by

}
- @if (searchTerm() || activeFilter() !== 'all' || activeSort() !== 'default') { -
+ + @if (hasActiveFilters()) { +
{{ filteredProjects().length }} {{ filteredProjects().length === 1 ? 'project' : 'projects' }} @@ -282,7 +416,7 @@

No Projects Match Your Criteri >

} - + @let statusInfo = getProjectStatus(project);
No Projects Match Your Criteri
- + + @let categoryInfo = getProjectCategory(project); +
+
+ {{ categoryInfo.icon }} + {{ categoryInfo.name }} +
+
+ + @if (isFavorite(project.projectIdentifier)) { All Projects Loaded
-@if (showFilterDropdown || showSortDropdown) { +@if (showFilterDropdown || showSortDropdown || showCategoryDropdown || showFundingRangeDropdown) {
} diff --git a/src/app/pages/explore/explore.component.ts b/src/app/pages/explore/explore.component.ts index a2d1597..690cf0e 100644 --- a/src/app/pages/explore/explore.component.ts +++ b/src/app/pages/explore/explore.component.ts @@ -17,10 +17,12 @@ import { filter } from 'rxjs/operators'; import { AgoPipe } from '../../pipes/ago.pipe'; import { formatDate } from '@angular/common'; import { TitleCasePipe } from '@angular/common'; +import { ProjectCategoryService, CategoryId, PROJECT_CATEGORIES } from '../../services/project-category.service'; type SortType = 'default' | 'funding' | 'endDate' | 'investors'; type FilterType = 'all' | 'active' | 'upcoming' | 'completed'; +type FundingRangeType = 'all' | 'early' | 'midway' | 'almost' | 'funded' | 'overfunded'; @Component({ selector: 'app-explore', @@ -54,15 +56,30 @@ export class ExploreComponent implements OnInit, AfterViewInit, OnDestroy { private utils = inject(UtilsService); private title = inject(TitleService); private denyService = inject(DenyService); + public categoryService = inject(ProjectCategoryService); + - searchTerm = signal(''); activeFilter = signal('all'); activeSort = signal('default'); + activeCategory = signal('all'); + activeFundingRange = signal('all'); + - filterOptions: FilterType[] = ['all', 'active', 'upcoming', 'completed']; sortOptions: SortType[] = ['default', 'funding', 'endDate', 'investors']; + categoryOptions = PROJECT_CATEGORIES; + fundingRangeOptions: { id: FundingRangeType; name: string; icon: string }[] = [ + { id: 'all', name: 'All Funding', icon: 'show_chart' }, + { id: 'early', name: '0-25%', icon: 'hourglass_empty' }, + { id: 'midway', name: '25-50%', icon: 'hourglass_bottom' }, + { id: 'almost', name: '50-99%', icon: 'hourglass_top' }, + { id: 'funded', name: '100%', icon: 'check_circle' }, + { id: 'overfunded', name: '100%+', icon: 'rocket_launch' } + ]; + + showCategoryDropdown = false; + showFundingRangeDropdown = false; failedBannerImages = signal>(new Set()); @@ -74,12 +91,13 @@ export class ExploreComponent implements OnInit, AfterViewInit, OnDestroy { filteredProjects: Signal = computed(() => { const projects = this.indexer.projects(); const search = this.searchTerm().toLowerCase().trim(); - const filter = this.activeFilter(); + const statusFilter = this.activeFilter(); const sort = this.activeSort(); + const category = this.activeCategory(); + const fundingRange = this.activeFundingRange(); - let filtered = projects.filter(project => { - + // Search filter if (search) { const name = (project.metadata?.['name'] || '').toLowerCase(); const about = (project.metadata?.['about'] || '').toLowerCase(); @@ -89,44 +107,88 @@ export class ExploreComponent implements OnInit, AfterViewInit, OnDestroy { } } - - if (filter === 'all') { + // Category filter + if (category !== 'all') { + const projectCategory = this.categoryService.categorizeProject(project); + if (projectCategory !== category) { + return false; + } + } + + // Funding range filter + if (fundingRange !== 'all') { + const fundingPercent = this.getFundingPercentage(project); + switch (fundingRange) { + case 'early': + if (fundingPercent >= 25) return false; + break; + case 'midway': + if (fundingPercent < 25 || fundingPercent >= 50) return false; + break; + case 'almost': + if (fundingPercent < 50 || fundingPercent >= 100) return false; + break; + case 'funded': + if (fundingPercent < 100 || fundingPercent > 100) return false; + break; + case 'overfunded': + if (fundingPercent <= 100) return false; + break; + } + } + + // Status filter + if (statusFilter === 'all') { return true; - } else if (filter === 'active') { + } else if (statusFilter === 'active') { return !this.isProjectNotStarted(project.details?.startDate) && !this.isProjectEnded(project.details?.expiryDate); - } else if (filter === 'upcoming') { + } else if (statusFilter === 'upcoming') { return this.isProjectNotStarted(project.details?.startDate); - } else if (filter === 'completed') { + } else if (statusFilter === 'completed') { return this.isProjectEnded(project.details?.expiryDate); } return true; }); - + // Sorting if (sort === 'funding') { filtered = [...filtered].sort((a, b) => { const percentA = this.getFundingPercentage(a); const percentB = this.getFundingPercentage(b); - return percentB - percentA; + return percentB - percentA; }); } else if (sort === 'endDate') { filtered = [...filtered].sort((a, b) => { const dateA = a.details?.expiryDate || 0; const dateB = b.details?.expiryDate || 0; - return dateA - dateB; + return dateA - dateB; }); } else if (sort === 'investors') { filtered = [...filtered].sort((a, b) => { const countA = a.stats?.investorCount || 0; const countB = b.stats?.investorCount || 0; - return countB - countA; + return countB - countA; }); } return filtered; }); + // Computed signal to get category counts for filter badges + categoryCounts = computed(() => { + return this.categoryService.getCategoryCounts(this.indexer.projects()); + }); + + // Check if any filters are active + hasActiveFilters = computed(() => { + return this.searchTerm() !== '' || + this.activeFilter() !== 'all' || + this.activeSort() !== 'default' || + this.activeCategory() !== 'all' || + this.activeFundingRange() !== 'all'; + }); + showFilterDropdown = false; showSortDropdown = false; @@ -190,7 +252,7 @@ export class ExploreComponent implements OnInit, AfterViewInit, OnDestroy { }); effect(() => { - console.log(`Filter/Sort changed - Filter: ${this.activeFilter()}, Sort: ${this.activeSort()}, Search: ${this.searchTerm()}`); + console.log(`Filters changed - Status: ${this.activeFilter()}, Sort: ${this.activeSort()}, Category: ${this.activeCategory()}, Funding: ${this.activeFundingRange()}, Search: ${this.searchTerm()}`); console.log(`Filtered projects count: ${this.filteredProjects().length}`); }); } @@ -258,6 +320,8 @@ export class ExploreComponent implements OnInit, AfterViewInit, OnDestroy { this.loadMoreQueued = false; this.document.removeEventListener('click', this.closeFilterDropdown); this.document.removeEventListener('click', this.closeSortDropdown); + this.document.removeEventListener('click', this.closeCategoryDropdown); + this.document.removeEventListener('click', this.closeFundingRangeDropdown); } private watchForScrollTrigger() { @@ -437,15 +501,86 @@ export class ExploreComponent implements OnInit, AfterViewInit, OnDestroy { this.searchTerm.set(''); this.activeFilter.set('all'); this.activeSort.set('default'); + this.activeCategory.set('all'); + this.activeFundingRange.set('all'); this.showFilterDropdown = false; this.showSortDropdown = false; + this.showCategoryDropdown = false; + this.showFundingRangeDropdown = false; this.showMobileFilters = false; } + setCategory(category: CategoryId): void { + this.activeCategory.set(category); + this.showCategoryDropdown = false; + } + + setFundingRange(range: FundingRangeType): void { + this.activeFundingRange.set(range); + this.showFundingRangeDropdown = false; + } + + toggleCategoryDropdown(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + this.showFilterDropdown = false; + this.showSortDropdown = false; + this.showFundingRangeDropdown = false; + this.showCategoryDropdown = !this.showCategoryDropdown; + if (this.showCategoryDropdown) { + setTimeout(() => this.document.addEventListener('click', this.closeCategoryDropdown), 10); + } else { + this.document.removeEventListener('click', this.closeCategoryDropdown); + } + } + + toggleFundingRangeDropdown(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + this.showFilterDropdown = false; + this.showSortDropdown = false; + this.showCategoryDropdown = false; + this.showFundingRangeDropdown = !this.showFundingRangeDropdown; + if (this.showFundingRangeDropdown) { + setTimeout(() => this.document.addEventListener('click', this.closeFundingRangeDropdown), 10); + } else { + this.document.removeEventListener('click', this.closeFundingRangeDropdown); + } + } + + closeCategoryDropdown = () => { + this.showCategoryDropdown = false; + this.document.removeEventListener('click', this.closeCategoryDropdown); + }; + + closeFundingRangeDropdown = () => { + this.showFundingRangeDropdown = false; + this.document.removeEventListener('click', this.closeFundingRangeDropdown); + }; + + getProjectCategory(project: IndexedProject): { id: string; name: string; icon: string; color: string } { + const categoryId = this.categoryService.categorizeProject(project); + const category = this.categoryService.getCategoryById(categoryId); + return category || { id: 'other', name: 'Other', icon: 'category', color: 'text-gray-500' }; + } + + getActiveCategoryName(): string { + if (this.activeCategory() === 'all') return 'All Categories'; + const category = this.categoryService.getCategoryById(this.activeCategory()); + return category?.name || 'All Categories'; + } + + getActiveFundingRangeName(): string { + const range = this.fundingRangeOptions.find(r => r.id === this.activeFundingRange()); + return range?.name || 'All Funding'; + } + toggleFilterDropdown(event: Event): void { event.preventDefault(); event.stopPropagation(); this.showSortDropdown = false; + this.showCategoryDropdown = false; + this.showFundingRangeDropdown = false; this.showFilterDropdown = !this.showFilterDropdown; if (this.showFilterDropdown) { setTimeout(() => this.document.addEventListener('click', this.closeFilterDropdown), 10); @@ -458,6 +593,8 @@ export class ExploreComponent implements OnInit, AfterViewInit, OnDestroy { event.preventDefault(); event.stopPropagation(); this.showFilterDropdown = false; + this.showCategoryDropdown = false; + this.showFundingRangeDropdown = false; this.showSortDropdown = !this.showSortDropdown; if (this.showSortDropdown) { setTimeout(() => this.document.addEventListener('click', this.closeSortDropdown), 10); diff --git a/src/app/services/project-category.service.ts b/src/app/services/project-category.service.ts new file mode 100644 index 0000000..9f49b8f --- /dev/null +++ b/src/app/services/project-category.service.ts @@ -0,0 +1,207 @@ +import { Injectable } from '@angular/core'; +import { IndexedProject } from './indexer.service'; + +export interface ProjectCategory { + id: string; + name: string; + icon: string; + keywords: string[]; + color: string; +} + +export const PROJECT_CATEGORIES: ProjectCategory[] = [ + { + id: 'defi', + name: 'DeFi', + icon: 'account_balance', + keywords: ['defi', 'decentralized finance', 'lending', 'borrowing', 'yield', 'liquidity', 'swap', 'dex', 'amm', 'staking', 'farming'], + color: 'text-purple-500' + }, + { + id: 'infrastructure', + name: 'Infrastructure', + icon: 'dns', + keywords: ['infrastructure', 'protocol', 'layer', 'scaling', 'bridge', 'oracle', 'node', 'validator', 'network', 'chain', 'sidechain', 'rollup'], + color: 'text-blue-500' + }, + { + id: 'nft', + name: 'NFT & Digital Assets', + icon: 'palette', + keywords: ['nft', 'collectible', 'art', 'digital art', 'marketplace', 'gallery', 'creator', 'ordinals', 'inscription'], + color: 'text-pink-500' + }, + { + id: 'payments', + name: 'Payments', + icon: 'payments', + keywords: ['payment', 'lightning', 'transaction', 'remittance', 'transfer', 'merchant', 'pos', 'point of sale', 'wallet'], + color: 'text-green-500' + }, + { + id: 'gaming', + name: 'Gaming & Metaverse', + icon: 'sports_esports', + keywords: ['game', 'gaming', 'metaverse', 'virtual', 'play', 'esports', 'gamefi', 'play-to-earn', 'p2e'], + color: 'text-orange-500' + }, + { + id: 'social', + name: 'Social & Community', + icon: 'groups', + keywords: ['social', 'community', 'dao', 'governance', 'voting', 'forum', 'chat', 'messaging', 'nostr', 'decentralized social'], + color: 'text-cyan-500' + }, + { + id: 'privacy', + name: 'Privacy & Security', + icon: 'security', + keywords: ['privacy', 'security', 'anonymous', 'encryption', 'coinjoin', 'mixer', 'confidential', 'zero knowledge', 'zk'], + color: 'text-red-500' + }, + { + id: 'tools', + name: 'Tools & Utilities', + icon: 'build', + keywords: ['tool', 'utility', 'analytics', 'explorer', 'dashboard', 'monitor', 'api', 'sdk', 'developer', 'dev tool'], + color: 'text-yellow-500' + }, + { + id: 'education', + name: 'Education & Media', + icon: 'school', + keywords: ['education', 'learn', 'course', 'tutorial', 'media', 'content', 'news', 'podcast', 'blog', 'research'], + color: 'text-indigo-500' + }, + { + id: 'other', + name: 'Other', + icon: 'category', + keywords: [], + color: 'text-gray-500' + } +]; + +export type CategoryId = 'all' | 'defi' | 'infrastructure' | 'nft' | 'payments' | 'gaming' | 'social' | 'privacy' | 'tools' | 'education' | 'other'; + +@Injectable({ + providedIn: 'root' +}) +export class ProjectCategoryService { + private categoryCache = new Map(); + + getCategories(): ProjectCategory[] { + return PROJECT_CATEGORIES; + } + + getCategoryById(id: string): ProjectCategory | undefined { + return PROJECT_CATEGORIES.find(cat => cat.id === id); + } + + /** + * Determines the category of a project based on its metadata + * Uses keyword matching on name and about fields + */ + categorizeProject(project: IndexedProject): CategoryId { + const projectId = project.projectIdentifier; + + // Check cache first + if (this.categoryCache.has(projectId)) { + return this.categoryCache.get(projectId)!; + } + + const name = (project.metadata?.['name'] || '').toLowerCase(); + const about = (project.metadata?.['about'] || '').toLowerCase(); + const combinedText = `${name} ${about}`; + + // Score each category based on keyword matches + let bestCategory: CategoryId = 'other'; + let bestScore = 0; + + for (const category of PROJECT_CATEGORIES) { + if (category.id === 'other') continue; + + let score = 0; + for (const keyword of category.keywords) { + if (combinedText.includes(keyword)) { + // Give higher score for exact word matches + const regex = new RegExp(`\\b${keyword}\\b`, 'i'); + if (regex.test(combinedText)) { + score += 2; + } else { + score += 1; + } + } + } + + if (score > bestScore) { + bestScore = score; + bestCategory = category.id as CategoryId; + } + } + + // Cache the result + this.categoryCache.set(projectId, bestCategory); + + return bestCategory; + } + + /** + * Get all categories present in a list of projects + */ + getCategoriesInProjects(projects: IndexedProject[]): CategoryId[] { + const categories = new Set(); + + for (const project of projects) { + categories.add(this.categorizeProject(project)); + } + + return Array.from(categories); + } + + /** + * Filter projects by category + */ + filterByCategory(projects: IndexedProject[], categoryId: CategoryId): IndexedProject[] { + if (categoryId === 'all') { + return projects; + } + + return projects.filter(project => this.categorizeProject(project) === categoryId); + } + + /** + * Get count of projects per category + */ + getCategoryCounts(projects: IndexedProject[]): Map { + const counts = new Map(); + + // Initialize all categories with 0 + counts.set('all', projects.length); + for (const category of PROJECT_CATEGORIES) { + counts.set(category.id as CategoryId, 0); + } + + // Count projects per category + for (const project of projects) { + const categoryId = this.categorizeProject(project); + counts.set(categoryId, (counts.get(categoryId) || 0) + 1); + } + + return counts; + } + + /** + * Clear the category cache (useful when project data updates) + */ + clearCache(): void { + this.categoryCache.clear(); + } + + /** + * Remove a specific project from cache + */ + invalidateProject(projectId: string): void { + this.categoryCache.delete(projectId); + } +}