feat: enhance recommendations with category tracking and UI improvements

This commit is contained in:
ersaayan
2025-11-22 18:33:51 +03:00
parent 32b415a97f
commit ccea8a40a4
5 changed files with 567 additions and 191 deletions
+31 -7
View File
@@ -21,14 +21,25 @@ interface OnboardingModalProps {
} }
const PLATFORMS = [ const PLATFORMS = [
{ id: 'windows', name: 'Windows', icon: '🪟' }, { id: 'windows', name: 'Windows' },
{ id: 'macos', name: 'macOS', icon: '🍎' }, { id: 'macos', name: 'macOS' },
{ id: 'ubuntu', name: 'Ubuntu', icon: '🐧' }, { id: 'ubuntu', name: 'Ubuntu' },
{ id: 'debian', name: 'Debian', icon: '🐧' }, { id: 'debian', name: 'Debian' },
{ id: 'arch', name: 'Arch Linux', icon: '🏛️' }, { id: 'arch', name: 'Arch Linux' },
{ id: 'fedora', name: 'Fedora', icon: '🎩' } { id: 'fedora', name: 'Fedora' }
] ]
const iconSlug: Record<string, string> = {
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({ export function OnboardingModal({
isOpen, isOpen,
onClose, onClose,
@@ -207,7 +218,20 @@ export function OnboardingModal({
: 'border-border hover:border-primary/50' : 'border-border hover:border-primary/50'
}`} }`}
> >
<div className="text-4xl mb-2">{platform.icon}</div> <div
className={`h-12 w-12 mx-auto mb-3 ${selectedOS === platform.id ? 'text-foreground' : 'text-muted-foreground'}`}
style={{
WebkitMaskImage: `url(${iconBase(iconSlug[platform.id] || 'linux')})`,
maskImage: `url(${iconBase(iconSlug[platform.id] || 'linux')})`,
WebkitMaskRepeat: 'no-repeat',
maskRepeat: 'no-repeat',
WebkitMaskSize: 'contain',
maskSize: 'contain',
WebkitMaskPosition: 'center',
maskPosition: 'center',
backgroundColor: 'currentColor'
} as React.CSSProperties}
/>
<div className="font-semibold text-sm">{platform.name}</div> <div className="font-semibold text-sm">{platform.name}</div>
</button> </button>
))} ))}
+403 -165
View File
@@ -1,13 +1,14 @@
"use client" "use client"
import { useState, useEffect } from 'react' import { useState, useEffect, useMemo } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' 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 { RecommendedPackage } from '@/types/recommendations'
import { Package } from '@/types' import { Package } from '@/types'
import { useLocale } from '@/contexts/LocaleContext' import { useLocale } from '@/contexts/LocaleContext'
import { useRecommendationProfile } from '@/hooks/useRecommendationProfile' import { useRecommendationProfile } from '@/hooks/useRecommendationProfile'
import { RECOMMENDATION_PRESETS } from '@/data/recommendationPresets'
interface RecommendationsSectionProps { interface RecommendationsSectionProps {
onPackageToggle: (pkg: Package) => void onPackageToggle: (pkg: Package) => void
@@ -15,6 +16,10 @@ interface RecommendationsSectionProps {
onCustomizeClick: () => void onCustomizeClick: () => void
} }
type ViewMode = 'grid' | 'compact'
type SortMode = 'recommended' | 'popularity' | 'preset'
type FilterCategory = 'all' | string
export function RecommendationsSection({ export function RecommendationsSection({
onPackageToggle, onPackageToggle,
selectedPackages, selectedPackages,
@@ -25,6 +30,10 @@ export function RecommendationsSection({
const [recommendations, setRecommendations] = useState<RecommendedPackage[]>([]) const [recommendations, setRecommendations] = useState<RecommendedPackage[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<ViewMode>('grid')
const [sortMode, setSortMode] = useState<SortMode>('recommended')
const [filterCategory, setFilterCategory] = useState<FilterCategory>('all')
const [isExpanded, setIsExpanded] = useState(true)
const fetchRecommendations = async () => { const fetchRecommendations = async () => {
if (!isProfileComplete()) { if (!isProfileComplete()) {
@@ -89,199 +98,428 @@ export function RecommendationsSection({
return selectedPackages.some(selected => selected.id === pkg.id) 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()) { if (!isProfileComplete()) {
return null return null
} }
return ( return (
<Card className="w-full"> <Card className="w-full">
<CardHeader> <CardHeader className="cursor-pointer hover:bg-accent/50 transition-colors" onClick={() => setIsExpanded(!isExpanded)}>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" /> <Sparkles className="h-5 w-5 text-primary" />
<div> <div>
<CardTitle className="text-lg"> <div className="flex items-center gap-2">
{t('recommendations.title')} <CardTitle className="text-lg">
</CardTitle> {t('recommendations.title')}
</CardTitle>
{!isExpanded && recommendations.length > 0 && (
<span className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
{recommendations.length} {t('recommendations.packages') || 'packages'}
</span>
)}
</div>
<CardDescription className="text-sm"> <CardDescription className="text-sm">
{t('recommendations.subtitle')} {t('recommendations.subtitle')}
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex items-center gap-2">
<Button {isExpanded && (
variant="outline" <>
size="sm" <Button
onClick={() => { variant="outline"
// Toggle: if all recommendations are selected, deselect them, otherwise select all size="sm"
const allSelected = recommendations.every(rec => onClick={(e) => {
selectedPackages.some(sel => sel.id === rec.id) e.stopPropagation()
) // Toggle: if all recommendations are selected, deselect them, otherwise select all
const allSelected = recommendations.every(rec =>
selectedPackages.some(sel => sel.id === rec.id)
)
if (allSelected) { if (allSelected) {
// Deselect all recommendations // Deselect all recommendations
recommendations.forEach(rec => { recommendations.forEach(rec => {
if (isPackageSelected(rec)) { if (isPackageSelected(rec)) {
onPackageToggle(rec) onPackageToggle(rec)
}
})
} else {
// Select all recommendations
recommendations.forEach(rec => {
if (!isPackageSelected(rec)) {
onPackageToggle(rec)
}
})
} }
}) }}
} else { disabled={loading || recommendations.length === 0}
// Select all recommendations >
recommendations.forEach(rec => { {recommendations.every(rec => selectedPackages.some(sel => sel.id === rec.id))
if (!isPackageSelected(rec)) { ? (t('common.deselect_all') || 'Deselect All')
onPackageToggle(rec) : (t('common.select_all') || 'Select All')}
} </Button>
}) <Button
} variant="outline"
}} size="sm"
disabled={loading || recommendations.length === 0} onClick={(e) => {
> e.stopPropagation()
{recommendations.every(rec => selectedPackages.some(sel => sel.id === rec.id)) fetchRecommendations()
? (t('common.deselect_all') || 'Deselect All') }}
: (t('common.select_all') || 'Select All')} disabled={loading}
</Button> >
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
{t('recommendations.refresh')}
</Button>
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation()
onCustomizeClick()
}}
>
<Settings className="h-4 w-4 mr-2" />
{t('recommendations.customize')}
</Button>
</>
)}
<Button <Button
variant="outline" variant="ghost"
size="sm" size="sm"
onClick={fetchRecommendations} className="h-8 w-8 p-0"
disabled={loading}
> >
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} /> {isExpanded ? (
{t('recommendations.refresh')} <ChevronUp className="h-4 w-4" />
</Button> ) : (
<Button <ChevronDown className="h-4 w-4" />
variant="outline" )}
size="sm"
onClick={onCustomizeClick}
>
<Settings className="h-4 w-4 mr-2" />
{t('recommendations.customize')}
</Button> </Button>
</div> </div>
</div> </div>
{/* Show user profile info */} {/* Show user profile info */}
<div className="flex flex-wrap gap-2 mt-3"> {isExpanded && (
<span className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary"> <div className="flex flex-wrap gap-2 mt-3">
{getEffectiveOS()} <span className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
</span> {getEffectiveOS()}
{profile.categories.map(cat => (
<span
key={cat}
className="text-xs px-2 py-1 rounded-full bg-secondary text-secondary-foreground"
>
{t(`categories.${cat}.name`)}
</span> </span>
))} {profile.categories.map(cat => (
</div> <span
</CardHeader> key={cat}
className="text-xs px-2 py-1 rounded-full bg-secondary text-secondary-foreground"
<CardContent>
{loading && (
<div className="text-center py-12">
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-4 text-primary" />
<p className="text-muted-foreground">{t('recommendations.loading')}</p>
</div>
)}
{error && (
<div className="text-center py-12">
<p className="text-destructive mb-4">{error}</p>
<Button onClick={fetchRecommendations} variant="outline">
Try Again
</Button>
</div>
)}
{!loading && !error && recommendations.length === 0 && (
<div className="text-center py-12">
<PackageIcon className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground mb-4">
{t('recommendations.no_recommendations')}
</p>
<Button onClick={onCustomizeClick} variant="outline">
<Settings className="h-4 w-4 mr-2" />
{t('recommendations.customize')}
</Button>
</div>
)}
{!loading && !error && recommendations.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{recommendations.map(pkg => (
<Card
key={pkg.id}
className={`relative overflow-hidden transition-all hover:shadow-lg cursor-pointer ${isPackageSelected(pkg) ? 'ring-2 ring-primary' : ''
}`}
onClick={() => onPackageToggle(pkg)}
> >
{pkg.presetMatch && ( {t(`categories.${cat}.name`)}
<div className="absolute top-2 right-2"> </span>
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold bg-primary text-primary-foreground">
<Star className="h-3 w-3" />
{t('recommendations.preset_badge')}
</span>
</div>
)}
<CardContent className="p-4">
<div className="flex items-start gap-3 mb-3">
<PackageIcon className="h-8 w-8 text-primary flex-shrink-0" />
<div className="flex-1 min-w-0">
<h3 className="font-semibold truncate">{pkg.name}</h3>
<p className="text-xs text-muted-foreground">{pkg.version}</p>
</div>
</div>
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">
{pkg.description}
</p>
{/* Recommendation score and reason */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{t('recommendations.score')}
</span>
<div className="flex items-center gap-1">
<div className="h-2 w-16 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${pkg.recommendationScore}%` }}
/>
</div>
<span className="text-xs font-semibold">{pkg.recommendationScore}%</span>
</div>
</div>
{pkg.recommendationReason && (
<div className="pt-2 border-t">
<p className="text-xs text-muted-foreground">
<span className="font-semibold">{t('recommendations.reason')}</span>
{' '}
{pkg.recommendationReason}
</p>
</div>
)}
</div>
<Button
variant={isPackageSelected(pkg) ? 'default' : 'outline'}
size="sm"
className="w-full mt-3"
onClick={(e) => {
e.stopPropagation()
onPackageToggle(pkg)
}}
>
{isPackageSelected(pkg) ? '✓ Selected' : t('recommendations.add_to_selection')}
</Button>
</CardContent>
</Card>
))} ))}
</div> </div>
)} )}
</CardContent>
{/* Filters and View Controls */}
{isExpanded && !loading && !error && recommendations.length > 0 && (
<div className="flex flex-wrap items-center gap-3 mt-4 pt-4 border-t">
{/* Category Filter Tabs */}
<div className="flex flex-wrap gap-2">
<Button
variant={filterCategory === 'all' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilterCategory('all')}
className="h-8 text-xs"
>
All ({recommendations.length})
</Button>
{profile.categories.map(cat => {
const count = getCategoryCount(cat)
return (
<Button
key={cat}
variant={filterCategory === cat ? 'default' : 'outline'}
size="sm"
onClick={() => setFilterCategory(cat)}
className="h-8 text-xs"
disabled={count === 0}
>
{getCategoryIcon(cat)} {t(`categories.${cat}.name`)} ({count})
</Button>
)
})}
</div>
<div className="flex items-center gap-2 ml-auto">
{/* Sort Dropdown */}
<div className="flex items-center gap-1 border rounded-md">
<Button
variant={sortMode === 'recommended' ? 'default' : 'ghost'}
size="sm"
onClick={() => setSortMode('recommended')}
className="h-8 text-xs rounded-r-none"
>
<Award className="h-3 w-3 mr-1" />
{t('recommendations.sort.recommended') || 'Best Match'}
</Button>
<Button
variant={sortMode === 'popularity' ? 'default' : 'ghost'}
size="sm"
onClick={() => setSortMode('popularity')}
className="h-8 text-xs rounded-none border-x"
>
<TrendingUp className="h-3 w-3 mr-1" />
{t('recommendations.sort.popular') || 'Popular'}
</Button>
<Button
variant={sortMode === 'preset' ? 'default' : 'ghost'}
size="sm"
onClick={() => setSortMode('preset')}
className="h-8 text-xs rounded-l-none"
>
<Star className="h-3 w-3 mr-1" />
{t('recommendations.sort.preset') || 'Essential'}
</Button>
</div>
{/* View Mode Toggle */}
<div className="flex items-center border rounded-md">
<Button
variant={viewMode === 'grid' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('grid')}
className="h-8 w-8 p-0 rounded-r-none"
>
<Grid3x3 className="h-4 w-4" />
</Button>
<Button
variant={viewMode === 'compact' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('compact')}
className="h-8 w-8 p-0 rounded-l-none border-l"
>
<List className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
</CardHeader>
{isExpanded && (
<CardContent>
{loading && (
<div className="text-center py-12">
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-4 text-primary" />
<p className="text-muted-foreground">{t('recommendations.loading')}</p>
</div>
)}
{error && (
<div className="text-center py-12">
<p className="text-destructive mb-4">{error}</p>
<Button onClick={fetchRecommendations} variant="outline">
Try Again
</Button>
</div>
)}
{!loading && !error && recommendations.length === 0 && (
<div className="text-center py-12">
<PackageIcon className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground mb-4">
{t('recommendations.no_recommendations')}
</p>
<Button onClick={onCustomizeClick} variant="outline">
<Settings className="h-4 w-4 mr-2" />
{t('recommendations.customize')}
</Button>
</div>
)}
{!loading && !error && recommendations.length > 0 && viewMode === 'grid' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredAndSortedRecommendations.map(pkg => (
<Card
key={pkg.id}
className={`relative overflow-hidden transition-all hover:shadow-lg cursor-pointer ${isPackageSelected(pkg) ? 'ring-2 ring-primary' : ''
}`}
onClick={() => onPackageToggle(pkg)}
>
{pkg.presetMatch && (
<div className="absolute top-2 right-2">
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold bg-primary text-primary-foreground">
<Star className="h-3 w-3" />
{t('recommendations.preset_badge')}
</span>
</div>
)}
<CardContent className="p-4">
<div className="flex items-start gap-3 mb-3">
<PackageIcon className="h-8 w-8 text-primary flex-shrink-0" />
<div className="flex-1 min-w-0">
<h3 className="font-semibold truncate">{pkg.name}</h3>
<p className="text-xs text-muted-foreground">{pkg.version}</p>
</div>
</div>
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">
{pkg.description}
</p>
{/* Recommendation score and reason */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{t('recommendations.score')}
</span>
<div className="flex items-center gap-1">
<div className="h-2 w-16 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${pkg.recommendationScore}%` }}
/>
</div>
<span className="text-xs font-semibold">{pkg.recommendationScore}%</span>
</div>
</div>
{pkg.recommendationReason && (
<div className="pt-2 border-t">
<p className="text-xs text-muted-foreground">
<span className="font-semibold">{t('recommendations.reason')}</span>
{' '}
{pkg.recommendationReason}
</p>
</div>
)}
</div>
<Button
variant={isPackageSelected(pkg) ? 'default' : 'outline'}
size="sm"
className="w-full mt-3"
onClick={(e) => {
e.stopPropagation()
onPackageToggle(pkg)
}}
>
{isPackageSelected(pkg) ? '✓ Selected' : t('recommendations.add_to_selection')}
</Button>
</CardContent>
</Card>
))}
</div>
)}
{/* Compact View Mode */}
{!loading && !error && recommendations.length > 0 && viewMode === 'compact' && (
<div className="space-y-2">
{filteredAndSortedRecommendations.map(pkg => (
<div
key={pkg.id}
className={`flex items-center gap-3 p-3 rounded-lg border-2 transition-all cursor-pointer hover:shadow-md ${isPackageSelected(pkg)
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}`}
onClick={() => onPackageToggle(pkg)}
>
{/* Package Icon */}
<PackageIcon className="h-6 w-6 text-primary flex-shrink-0" />
{/* Package Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-semibold text-sm truncate">{pkg.name}</h4>
{pkg.presetMatch && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-semibold bg-primary text-primary-foreground">
<Star className="h-2.5 w-2.5" />
Essential
</span>
)}
<span className="text-xs text-muted-foreground">{pkg.version}</span>
</div>
<p className="text-xs text-muted-foreground truncate">{pkg.description}</p>
</div>
{/* Score Badge */}
<div className="flex items-center gap-2 flex-shrink-0">
<div className="text-right">
<div className="text-xs font-semibold text-primary">
{pkg.recommendationScore}%
</div>
<div className="text-xs text-muted-foreground">
match
</div>
</div>
{/* Info Tooltip */}
{pkg.recommendationReason && (
<div className="relative group">
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
<div className="absolute right-0 top-full mt-2 w-64 p-2 bg-popover text-popover-foreground text-xs rounded-md shadow-lg border opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10">
<p className="font-semibold mb-1">Why recommended?</p>
<p>{pkg.recommendationReason}</p>
</div>
</div>
)}
{/* Select Button */}
<Button
variant={isPackageSelected(pkg) ? 'default' : 'outline'}
size="sm"
className="ml-2"
onClick={(e) => {
e.stopPropagation()
onPackageToggle(pkg)
}}
>
{isPackageSelected(pkg) ? '✓' : '+'}
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
)}
</Card> </Card>
) )
} }
+14 -2
View File
@@ -187,7 +187,13 @@ const translations = {
view_details: "View Details", view_details: "View Details",
add_to_selection: "Add to Selection", add_to_selection: "Add to Selection",
reason: "Why recommended:", 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: { tr: {
@@ -372,7 +378,13 @@ const translations = {
view_details: "Detayları Gör", view_details: "Detayları Gör",
add_to_selection: "Seçime Ekle", add_to_selection: "Seçime Ekle",
reason: "Neden önerildi:", 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"
}
} }
} }
} }
+118 -17
View File
@@ -10,6 +10,7 @@ import {
getPresetPackageNames, getPresetPackageNames,
getPresetPriority, getPresetPriority,
getRecommendationReason, getRecommendationReason,
RECOMMENDATION_PRESETS,
} from "@/data/recommendationPresets"; } from "@/data/recommendationPresets";
/** /**
@@ -34,36 +35,46 @@ export class RecommendationService {
// Step 1: Get preset package names for the user's categories and platform // Step 1: Get preset package names for the user's categories and platform
const presetPackageNames = getPresetPackageNames(categories, platform_id); const presetPackageNames = getPresetPackageNames(categories, platform_id);
// Step 2: Fetch packages from database // Step 2: Fetch packages from database with category tracking
// First, get preset packages // Map to track which category each package came from
const presetPackages = await this.fetchPresetPackages( const packageCategoryMap = new Map<string, UserCategory>();
presetPackageNames,
platform_id // First, get preset packages (tagged with their categories)
); const presetPackagesWithCategories =
await this.fetchPresetPackagesWithCategories(
categories,
platform_id,
packageCategoryMap
);
// Then, get additional packages from categories // Then, get additional packages from categories
const categoryPackages = await this.fetchCategoryPackages( const categoryPackagesMap = await this.fetchCategoryPackagesWithTracking(
categories, categories,
platform_id, 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 // Step 3: Combine and deduplicate
const allPackages = this.deduplicatePackages([ const allPackages = this.deduplicatePackages([
...presetPackages, ...presetPackagesWithCategories,
...categoryPackages, ...categoryPackagesMap,
]); ]);
// Step 4: Score and rank packages // Step 4: Score and rank packages - distribute categories evenly for untracked packages
const scoredPackages = allPackages.map((pkg) => const scoredPackages = allPackages.map((pkg, index) => {
this.scorePackage( // If not in map, distribute evenly across categories
const matchedCategory =
packageCategoryMap.get(pkg.id) || categories[index % categories.length];
return this.scorePackage(
pkg, pkg,
categories, categories,
platform_id, platform_id,
presetPackageNames, presetPackageNames,
experienceLevel experienceLevel,
) matchedCategory
); );
});
// Step 5: Sort by score and limit results // Step 5: Sort by score and limit results
scoredPackages.sort( scoredPackages.sort(
@@ -73,6 +84,52 @@ export class RecommendationService {
return scoredPackages.slice(0, limit); return scoredPackages.slice(0, limit);
} }
/**
* Fetch packages that match preset names with category tracking
*/
private static async fetchPresetPackagesWithCategories(
categories: UserCategory[],
platformId: string,
categoryMap: Map<string, UserCategory>
): Promise<Package[]> {
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 * Fetch packages that match preset names
* Optimized: Uses single query instead of N queries * 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<string, UserCategory>
): Promise<Package[]> {
try {
const allPackages: Package[] = [];
const seenIds = new Set<string>();
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 * Fetch packages based on categories
* Now properly uses database category filtering * Now properly uses database category filtering
@@ -201,7 +300,8 @@ export class RecommendationService {
categories: UserCategory[], categories: UserCategory[],
platformId: string, platformId: string,
presetPackageNames: string[], presetPackageNames: string[],
experienceLevel?: ExperienceLevel experienceLevel?: ExperienceLevel,
matchedCategory?: UserCategory
): RecommendedPackage { ): RecommendedPackage {
let score = 0; let score = 0;
let reason = ""; let reason = "";
@@ -273,6 +373,7 @@ export class RecommendationService {
recommendationScore: finalScore, recommendationScore: finalScore,
recommendationReason: reason, recommendationReason: reason,
presetMatch: isPresetMatch, presetMatch: isPresetMatch,
matchedCategory: matchedCategory,
}; };
} }
+1
View File
@@ -64,6 +64,7 @@ export interface RecommendedPackage {
recommendationScore: number; recommendationScore: number;
recommendationReason: string; recommendationReason: string;
presetMatch?: boolean; presetMatch?: boolean;
matchedCategory?: UserCategory; // Which user category this package matched
} }
/** /**