From ccea8a40a45894f2d18b69eb3ae6f7b28a53ea1a Mon Sep 17 00:00:00 2001 From: ersaayan Date: Sat, 22 Nov 2025 18:33:51 +0300 Subject: [PATCH] feat: enhance recommendations with category tracking and UI improvements --- src/components/OnboardingModal.tsx | 38 +- src/components/RecommendationsSection.tsx | 568 +++++++++++++++------- src/contexts/LocaleContext.tsx | 16 +- src/services/recommendationService.ts | 135 ++++- src/types/recommendations.ts | 1 + 5 files changed, 567 insertions(+), 191 deletions(-) diff --git a/src/components/OnboardingModal.tsx b/src/components/OnboardingModal.tsx index a4057c5..c8c19cf 100644 --- a/src/components/OnboardingModal.tsx +++ b/src/components/OnboardingModal.tsx @@ -21,14 +21,25 @@ interface OnboardingModalProps { } const PLATFORMS = [ - { id: 'windows', name: 'Windows', icon: '🪟' }, - { id: 'macos', name: 'macOS', icon: '🍎' }, - { id: 'ubuntu', name: 'Ubuntu', icon: '🐧' }, - { id: 'debian', name: 'Debian', icon: '🐧' }, - { id: 'arch', name: 'Arch Linux', icon: '🏛️' }, - { id: 'fedora', name: 'Fedora', icon: '🎩' } + { id: 'windows', name: 'Windows' }, + { id: 'macos', name: 'macOS' }, + { id: 'ubuntu', name: 'Ubuntu' }, + { id: 'debian', name: 'Debian' }, + { id: 'arch', name: 'Arch Linux' }, + { id: 'fedora', name: 'Fedora' } ] +const iconSlug: Record = { + debian: 'debian', + ubuntu: 'ubuntu', + fedora: 'fedora', + arch: 'archlinux', + windows: 'windows', + macos: 'apple' +} + +const iconBase = (slug: string) => `https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/${slug}.svg` + export function OnboardingModal({ isOpen, onClose, @@ -207,7 +218,20 @@ export function OnboardingModal({ : 'border-border hover:border-primary/50' }`} > -
{platform.icon}
+
{platform.name}
))} diff --git a/src/components/RecommendationsSection.tsx b/src/components/RecommendationsSection.tsx index 07a95ed..a8188de 100644 --- a/src/components/RecommendationsSection.tsx +++ b/src/components/RecommendationsSection.tsx @@ -1,13 +1,14 @@ "use client" -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo } from 'react' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' -import { Sparkles, RefreshCw, Settings, Package as PackageIcon, Star } from 'lucide-react' +import { Sparkles, RefreshCw, Settings, Package as PackageIcon, Star, Grid3x3, List, TrendingUp, Award, Info, ChevronDown, ChevronUp } from 'lucide-react' import { RecommendedPackage } from '@/types/recommendations' import { Package } from '@/types' import { useLocale } from '@/contexts/LocaleContext' import { useRecommendationProfile } from '@/hooks/useRecommendationProfile' +import { RECOMMENDATION_PRESETS } from '@/data/recommendationPresets' interface RecommendationsSectionProps { onPackageToggle: (pkg: Package) => void @@ -15,6 +16,10 @@ interface RecommendationsSectionProps { onCustomizeClick: () => void } +type ViewMode = 'grid' | 'compact' +type SortMode = 'recommended' | 'popularity' | 'preset' +type FilterCategory = 'all' | string + export function RecommendationsSection({ onPackageToggle, selectedPackages, @@ -25,6 +30,10 @@ export function RecommendationsSection({ const [recommendations, setRecommendations] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + const [viewMode, setViewMode] = useState('grid') + const [sortMode, setSortMode] = useState('recommended') + const [filterCategory, setFilterCategory] = useState('all') + const [isExpanded, setIsExpanded] = useState(true) const fetchRecommendations = async () => { if (!isProfileComplete()) { @@ -89,199 +98,428 @@ export function RecommendationsSection({ return selectedPackages.some(selected => selected.id === pkg.id) } + // Get category icon from presets + const getCategoryIcon = (category: string): string => { + const preset = RECOMMENDATION_PRESETS.find(p => p.category === category) + return preset?.icon || '📦' + } + + // Get package count per category + const getCategoryCount = (category: string): number => { + return recommendations.filter(pkg => pkg.matchedCategory === category).length + } + + // Filter and sort recommendations + const filteredAndSortedRecommendations = useMemo(() => { + let result = [...recommendations] + + // Filter by category + if (filterCategory !== 'all') { + result = result.filter(pkg => pkg.matchedCategory === filterCategory) + } + + // Sort + switch (sortMode) { + case 'popularity': + result.sort((a, b) => (b.popularity || 0) - (a.popularity || 0)) + break + case 'preset': + result.sort((a, b) => { + if (a.presetMatch && !b.presetMatch) return -1 + if (!a.presetMatch && b.presetMatch) return 1 + return b.recommendationScore - a.recommendationScore + }) + break + case 'recommended': + default: + result.sort((a, b) => b.recommendationScore - a.recommendationScore) + break + } + + return result + }, [recommendations, filterCategory, sortMode]) + if (!isProfileComplete()) { return null } return ( - + setIsExpanded(!isExpanded)}>
- - {t('recommendations.title')} - +
+ + {t('recommendations.title')} + + {!isExpanded && recommendations.length > 0 && ( + + {recommendations.length} {t('recommendations.packages') || 'packages'} + + )} +
{t('recommendations.subtitle')}
-
- + }} + disabled={loading || recommendations.length === 0} + > + {recommendations.every(rec => selectedPackages.some(sel => sel.id === rec.id)) + ? (t('common.deselect_all') || 'Deselect All') + : (t('common.select_all') || 'Select All')} + + + + + )} -
{/* Show user profile info */} -
- - {getEffectiveOS()} - - {profile.categories.map(cat => ( - - {t(`categories.${cat}.name`)} + {isExpanded && ( +
+ + {getEffectiveOS()} - ))} -
- - - - {loading && ( -
- -

{t('recommendations.loading')}

-
- )} - - {error && ( -
-

{error}

- -
- )} - - {!loading && !error && recommendations.length === 0 && ( -
- -

- {t('recommendations.no_recommendations')} -

- -
- )} - - {!loading && !error && recommendations.length > 0 && ( -
- {recommendations.map(pkg => ( - onPackageToggle(pkg)} + {profile.categories.map(cat => ( + - {pkg.presetMatch && ( -
- - - {t('recommendations.preset_badge')} - -
- )} - - -
- -
-

{pkg.name}

-

{pkg.version}

-
-
- -

- {pkg.description} -

- - {/* Recommendation score and reason */} -
-
- - {t('recommendations.score')} - -
-
-
-
- {pkg.recommendationScore}% -
-
- - {pkg.recommendationReason && ( -
-

- {t('recommendations.reason')} - {' '} - {pkg.recommendationReason} -

-
- )} -
- - - - + {t(`categories.${cat}.name`)} + ))}
)} -
+ + {/* Filters and View Controls */} + {isExpanded && !loading && !error && recommendations.length > 0 && ( +
+ {/* Category Filter Tabs */} +
+ + {profile.categories.map(cat => { + const count = getCategoryCount(cat) + return ( + + ) + })} +
+ +
+ {/* Sort Dropdown */} +
+ + + +
+ + {/* View Mode Toggle */} +
+ + +
+
+
+ )} + + + {isExpanded && ( + + {loading && ( +
+ +

{t('recommendations.loading')}

+
+ )} + + {error && ( +
+

{error}

+ +
+ )} + + {!loading && !error && recommendations.length === 0 && ( +
+ +

+ {t('recommendations.no_recommendations')} +

+ +
+ )} + + {!loading && !error && recommendations.length > 0 && viewMode === 'grid' && ( +
+ {filteredAndSortedRecommendations.map(pkg => ( + onPackageToggle(pkg)} + > + {pkg.presetMatch && ( +
+ + + {t('recommendations.preset_badge')} + +
+ )} + + +
+ +
+

{pkg.name}

+

{pkg.version}

+
+
+ +

+ {pkg.description} +

+ + {/* Recommendation score and reason */} +
+
+ + {t('recommendations.score')} + +
+
+
+
+ {pkg.recommendationScore}% +
+
+ + {pkg.recommendationReason && ( +
+

+ {t('recommendations.reason')} + {' '} + {pkg.recommendationReason} +

+
+ )} +
+ + + + + ))} +
+ )} + + {/* Compact View Mode */} + {!loading && !error && recommendations.length > 0 && viewMode === 'compact' && ( +
+ {filteredAndSortedRecommendations.map(pkg => ( +
onPackageToggle(pkg)} + > + {/* Package Icon */} + + + {/* Package Info */} +
+
+

{pkg.name}

+ {pkg.presetMatch && ( + + + Essential + + )} + {pkg.version} +
+

{pkg.description}

+
+ + {/* Score Badge */} +
+
+
+ {pkg.recommendationScore}% +
+
+ match +
+
+ + {/* Info Tooltip */} + {pkg.recommendationReason && ( +
+ +
+

Why recommended?

+

{pkg.recommendationReason}

+
+
+ )} + + {/* Select Button */} + +
+
+ ))} +
+ )} +
+ )}
) } diff --git a/src/contexts/LocaleContext.tsx b/src/contexts/LocaleContext.tsx index 116ddbc..3233454 100644 --- a/src/contexts/LocaleContext.tsx +++ b/src/contexts/LocaleContext.tsx @@ -187,7 +187,13 @@ const translations = { view_details: "View Details", add_to_selection: "Add to Selection", reason: "Why recommended:", - based_on: "Based on your interests in:" + based_on: "Based on your interests in:", + packages: "packages", + sort: { + recommended: "Best Match", + popular: "Popular", + preset: "Essential" + } } }, tr: { @@ -372,7 +378,13 @@ const translations = { view_details: "Detayları Gör", add_to_selection: "Seçime Ekle", reason: "Neden önerildi:", - based_on: "İlgi alanlarınıza göre:" + based_on: "İlgi alanlarınıza göre:", + packages: "paket", + sort: { + recommended: "En Uygun", + popular: "Popüler", + preset: "Temel" + } } } } diff --git a/src/services/recommendationService.ts b/src/services/recommendationService.ts index 7b19895..93d0e9e 100644 --- a/src/services/recommendationService.ts +++ b/src/services/recommendationService.ts @@ -10,6 +10,7 @@ import { getPresetPackageNames, getPresetPriority, getRecommendationReason, + RECOMMENDATION_PRESETS, } from "@/data/recommendationPresets"; /** @@ -34,36 +35,46 @@ export class RecommendationService { // Step 1: Get preset package names for the user's categories and platform const presetPackageNames = getPresetPackageNames(categories, platform_id); - // Step 2: Fetch packages from database - // First, get preset packages - const presetPackages = await this.fetchPresetPackages( - presetPackageNames, - platform_id - ); + // Step 2: Fetch packages from database with category tracking + // Map to track which category each package came from + const packageCategoryMap = new Map(); + + // First, get preset packages (tagged with their categories) + const presetPackagesWithCategories = + await this.fetchPresetPackagesWithCategories( + categories, + platform_id, + packageCategoryMap + ); // Then, get additional packages from categories - const categoryPackages = await this.fetchCategoryPackages( + const categoryPackagesMap = await this.fetchCategoryPackagesWithTracking( categories, platform_id, - limit * 2 // Fetch more to ensure we have enough after filtering + limit * 2, // Fetch more to ensure we have enough after filtering + packageCategoryMap ); // Step 3: Combine and deduplicate const allPackages = this.deduplicatePackages([ - ...presetPackages, - ...categoryPackages, + ...presetPackagesWithCategories, + ...categoryPackagesMap, ]); - // Step 4: Score and rank packages - const scoredPackages = allPackages.map((pkg) => - this.scorePackage( + // Step 4: Score and rank packages - distribute categories evenly for untracked packages + const scoredPackages = allPackages.map((pkg, index) => { + // If not in map, distribute evenly across categories + const matchedCategory = + packageCategoryMap.get(pkg.id) || categories[index % categories.length]; + return this.scorePackage( pkg, categories, platform_id, presetPackageNames, - experienceLevel - ) - ); + experienceLevel, + matchedCategory + ); + }); // Step 5: Sort by score and limit results scoredPackages.sort( @@ -73,6 +84,52 @@ export class RecommendationService { return scoredPackages.slice(0, limit); } + /** + * Fetch packages that match preset names with category tracking + */ + private static async fetchPresetPackagesWithCategories( + categories: UserCategory[], + platformId: string, + categoryMap: Map + ): Promise { + const packages: Package[] = []; + + for (const category of categories) { + const preset = RECOMMENDATION_PRESETS.find( + (p) => p.category === category + ); + if (!preset) continue; + + const categoryPackageNames = preset.packages + .filter((p) => p.platforms.includes(platformId)) + .map((p) => p.packageName); + + for (const name of categoryPackageNames) { + const result = await PackageService.getMany({ + platform_id: platformId, + search: name, + limit: 5, + sort_by: "popularity_score", + sort_order: "desc", + }); + + const exactMatch = result.packages.find( + (pkg) => pkg.name.toLowerCase() === name.toLowerCase() + ); + + if (exactMatch) { + packages.push(exactMatch); + categoryMap.set(exactMatch.id, category); + } else if (result.packages.length > 0) { + packages.push(result.packages[0]); + categoryMap.set(result.packages[0].id, category); + } + } + } + + return packages; + } + /** * Fetch packages that match preset names * Optimized: Uses single query instead of N queries @@ -121,6 +178,48 @@ export class RecommendationService { } } + /** + * Fetch packages based on categories with tracking + */ + private static async fetchCategoryPackagesWithTracking( + categories: UserCategory[], + platformId: string, + limit: number, + categoryMap: Map + ): Promise { + try { + const allPackages: Package[] = []; + const seenIds = new Set(); + const perCategory = Math.ceil(limit / categories.length); + + for (const category of categories) { + const result = await PackageService.getMany({ + platform_id: platformId, + limit: perCategory, + sort_by: "popularity_score", + sort_order: "desc", + }); + + // Add packages without duplicates and tag with category + for (const pkg of result.packages) { + if (!seenIds.has(pkg.id)) { + seenIds.add(pkg.id); + allPackages.push(pkg); + // Tag this package with the category + if (!categoryMap.has(pkg.id)) { + categoryMap.set(pkg.id, category); + } + } + } + } + + return allPackages; + } catch (error) { + console.error("Error fetching category packages:", error); + return []; + } + } + /** * Fetch packages based on categories * Now properly uses database category filtering @@ -201,7 +300,8 @@ export class RecommendationService { categories: UserCategory[], platformId: string, presetPackageNames: string[], - experienceLevel?: ExperienceLevel + experienceLevel?: ExperienceLevel, + matchedCategory?: UserCategory ): RecommendedPackage { let score = 0; let reason = ""; @@ -273,6 +373,7 @@ export class RecommendationService { recommendationScore: finalScore, recommendationReason: reason, presetMatch: isPresetMatch, + matchedCategory: matchedCategory, }; } diff --git a/src/types/recommendations.ts b/src/types/recommendations.ts index 94b0826..9296aba 100644 --- a/src/types/recommendations.ts +++ b/src/types/recommendations.ts @@ -64,6 +64,7 @@ export interface RecommendedPackage { recommendationScore: number; recommendationReason: string; presetMatch?: boolean; + matchedCategory?: UserCategory; // Which user category this package matched } /**