mirror of
https://github.com/yusufipk/RepoHub.git
synced 2026-09-11 10:36:07 +00:00
feat: enhance recommendations with category tracking and UI improvements
This commit is contained in:
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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,30 +98,81 @@ 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>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<CardTitle className="text-lg">
|
<CardTitle className="text-lg">
|
||||||
{t('recommendations.title')}
|
{t('recommendations.title')}
|
||||||
</CardTitle>
|
</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">
|
||||||
|
{isExpanded && (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
// Toggle: if all recommendations are selected, deselect them, otherwise select all
|
// Toggle: if all recommendations are selected, deselect them, otherwise select all
|
||||||
const allSelected = recommendations.every(rec =>
|
const allSelected = recommendations.every(rec =>
|
||||||
selectedPackages.some(sel => sel.id === rec.id)
|
selectedPackages.some(sel => sel.id === rec.id)
|
||||||
@@ -143,7 +203,10 @@ export function RecommendationsSection({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={fetchRecommendations}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
fetchRecommendations()
|
||||||
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||||
@@ -152,15 +215,32 @@ export function RecommendationsSection({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onCustomizeClick}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onCustomizeClick()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Settings className="h-4 w-4 mr-2" />
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
{t('recommendations.customize')}
|
{t('recommendations.customize')}
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Show user profile info */}
|
{/* Show user profile info */}
|
||||||
|
{isExpanded && (
|
||||||
<div className="flex flex-wrap gap-2 mt-3">
|
<div className="flex flex-wrap gap-2 mt-3">
|
||||||
<span className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
|
<span className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
|
||||||
{getEffectiveOS()}
|
{getEffectiveOS()}
|
||||||
@@ -174,8 +254,95 @@ export function RecommendationsSection({
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
</CardHeader>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
@@ -206,9 +373,9 @@ export function RecommendationsSection({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && recommendations.length > 0 && (
|
{!loading && !error && recommendations.length > 0 && viewMode === 'grid' && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{recommendations.map(pkg => (
|
{filteredAndSortedRecommendations.map(pkg => (
|
||||||
<Card
|
<Card
|
||||||
key={pkg.id}
|
key={pkg.id}
|
||||||
className={`relative overflow-hidden transition-all hover:shadow-lg cursor-pointer ${isPackageSelected(pkg) ? 'ring-2 ring-primary' : ''
|
className={`relative overflow-hidden transition-all hover:shadow-lg cursor-pointer ${isPackageSelected(pkg) ? 'ring-2 ring-primary' : ''
|
||||||
@@ -281,7 +448,78 @@ export function RecommendationsSection({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user