refactor: Remove experience level selection and simplify category display in onboarding modal.

This commit is contained in:
Yusuf İpek
2025-11-23 13:43:31 +03:00
parent 4eac0917d0
commit 24c80559c8
5 changed files with 278 additions and 648 deletions
+20 -66
View File
@@ -5,9 +5,8 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { X, ChevronRight, ChevronLeft, Sparkles } from 'lucide-react' import { X, ChevronRight, ChevronLeft, Sparkles } from 'lucide-react'
import { UserCategory, ExperienceLevel } from '@/types/recommendations' import { UserCategory } from '@/types/recommendations'
import { useLocale } from '@/contexts/LocaleContext' import { useLocale } from '@/contexts/LocaleContext'
import { RECOMMENDATION_PRESETS } from '@/data/recommendationPresets'
import { CATEGORY_ICONS } from '@/constants/categoryIcons' import { CATEGORY_ICONS } from '@/constants/categoryIcons'
interface OnboardingModalProps { interface OnboardingModalProps {
@@ -16,7 +15,6 @@ interface OnboardingModalProps {
onComplete: (data: { onComplete: (data: {
categories: UserCategory[] categories: UserCategory[]
selectedOS?: string selectedOS?: string
experienceLevel: ExperienceLevel
}) => void }) => void
detectedOS: string detectedOS: string
} }
@@ -54,7 +52,6 @@ export function OnboardingModal({
const [selectedOS, setSelectedOS] = useState<string>( const [selectedOS, setSelectedOS] = useState<string>(
detectedOS !== 'unknown' ? detectedOS : 'ubuntu' detectedOS !== 'unknown' ? detectedOS : 'ubuntu'
) )
const [experienceLevel, setExperienceLevel] = useState<ExperienceLevel>('beginner')
// Reset state when modal opens // Reset state when modal opens
useEffect(() => { useEffect(() => {
@@ -72,16 +69,12 @@ export function OnboardingModal({
if (prev.includes(category)) { if (prev.includes(category)) {
return prev.filter(c => c !== category) return prev.filter(c => c !== category)
} }
// Limit to 3 categories
if (prev.length >= 3) {
return [...prev.slice(1), category]
}
return [...prev, category] return [...prev, category]
}) })
} }
const handleNext = () => { const handleNext = () => {
if (step < 3) { if (step < 2) {
setStep(step + 1) setStep(step + 1)
} }
} }
@@ -99,8 +92,7 @@ export function OnboardingModal({
onComplete({ onComplete({
categories: selectedCategories, categories: selectedCategories,
selectedOS: selectedOS, selectedOS: selectedOS
experienceLevel
}) })
// Close modal // Close modal
@@ -109,8 +101,7 @@ export function OnboardingModal({
const canProceed = () => { const canProceed = () => {
if (step === 1) return selectedCategories.length > 0 if (step === 1) return selectedCategories.length > 0
if (step === 2) return selectedOS !== 'unknown' if (step === 2) return true
if (step === 3) return true
return false return false
} }
@@ -136,7 +127,7 @@ export function OnboardingModal({
{/* Progress indicator */} {/* Progress indicator */}
<div className="flex gap-2 mt-4"> <div className="flex gap-2 mt-4">
{[1, 2, 3].map(i => ( {[1, 2].map(i => (
<div <div
key={i} key={i}
className={`h-2 flex-1 rounded-full transition-colors ${i <= step ? 'bg-primary' : 'bg-secondary' className={`h-2 flex-1 rounded-full transition-colors ${i <= step ? 'bg-primary' : 'bg-secondary'
@@ -160,30 +151,25 @@ export function OnboardingModal({
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{RECOMMENDATION_PRESETS.map(preset => { {(Object.entries(CATEGORY_ICONS) as [UserCategory, any][]).map(([category, Icon]) => {
const Icon = CATEGORY_ICONS[preset.category]
return ( return (
<button <button
key={preset.category} key={category}
onClick={() => handleCategoryToggle(preset.category)} onClick={() => handleCategoryToggle(category)}
className={`p-4 rounded-lg border-2 text-left transition-all hover:scale-105 ${selectedCategories.includes(preset.category) className={`p-4 rounded-lg border-2 text-left transition-all hover:scale-105 ${selectedCategories.includes(category)
? 'border-primary bg-primary/10' ? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50' : 'border-border hover:border-primary/50'
}`} }`}
> >
<div className="flex items-start gap-3"> <div className="flex items-center gap-3 mb-2">
<div className="p-2 rounded-md bg-background border"> <Icon className="h-6 w-6 text-primary" />
{Icon && <Icon className="h-6 w-6 text-primary" />} <h4 className="font-semibold capitalize">
</div> {t(`categories.${category}.name`)}
<div className="flex-1 min-w-0"> </h4>
<h4 className="font-semibold capitalize">
{t(`categories.${preset.category}.name`)}
</h4>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{t(`categories.${preset.category}.description`)}
</p>
</div>
</div> </div>
<p className="text-sm text-muted-foreground">
{t(`categories.${category}.description`)}
</p>
</button> </button>
) )
})} })}
@@ -245,39 +231,7 @@ export function OnboardingModal({
</div> </div>
)} )}
{/* Step 3: Experience Level */}
{step === 3 && (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold mb-2">
{t('onboarding.step3.title')}
</h3>
<p className="text-sm text-muted-foreground mb-4">
{t('onboarding.step3.description')}
</p>
</div>
<div className="space-y-3">
{(['beginner', 'intermediate', 'advanced'] as ExperienceLevel[]).map(level => (
<button
key={level}
onClick={() => setExperienceLevel(level)}
className={`w-full p-4 rounded-lg border-2 text-left transition-all hover:scale-[1.02] ${experienceLevel === level
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50'
}`}
>
<h4 className="font-semibold capitalize mb-1">
{t(`onboarding.step3.levels.${level}.name`)}
</h4>
<p className="text-sm text-muted-foreground">
{t(`onboarding.step3.levels.${level}.description`)}
</p>
</button>
))}
</div>
</div>
)}
{/* Navigation Buttons */} {/* Navigation Buttons */}
<div className="flex gap-3 pt-4"> <div className="flex gap-3 pt-4">
@@ -292,7 +246,7 @@ export function OnboardingModal({
</Button> </Button>
)} )}
{step < 3 ? ( {step < 2 ? (
<Button <Button
onClick={handleNext} onClick={handleNext}
disabled={!canProceed()} disabled={!canProceed()}
@@ -307,8 +261,8 @@ export function OnboardingModal({
disabled={!canProceed()} disabled={!canProceed()}
className="flex-1" className="flex-1"
> >
{t('onboarding.complete')} <Sparkles className="h-4 w-4 mr-2" />
<Sparkles className="h-4 w-4 ml-2" /> {t('common.finish')}
</Button> </Button>
)} )}
</div> </div>
+6 -8
View File
@@ -14,7 +14,8 @@ const translations = {
next: "Next", next: "Next",
back: "Back", back: "Back",
select_all: "Select All", select_all: "Select All",
deselect_all: "Deselect All" deselect_all: "Deselect All",
finish: "Finish"
}, },
platform: { platform: {
select: "Select Your Platform", select: "Select Your Platform",
@@ -117,9 +118,7 @@ const translations = {
complete: "Get My Recommendations", complete: "Get My Recommendations",
step1: { step1: {
title: "What do you want to use your computer for?", title: "What do you want to use your computer for?",
description: "Select up to 3 categories that match your needs", description: "Select the categories that match your needs"
selected: "{count} selected (max 3)",
all_selected: "All {count} categories selected"
}, },
step2: { step2: {
title: "Select your operating system", title: "Select your operating system",
@@ -205,7 +204,8 @@ const translations = {
next: "İleri", next: "İleri",
back: "Geri", back: "Geri",
select_all: "Tümünü Seç", select_all: "Tümünü Seç",
deselect_all: "Seçimi Kaldır" deselect_all: "Seçimi Kaldır",
finish: "Bitir"
}, },
platform: { platform: {
select: "Platformunuzu Seçin", select: "Platformunuzu Seçin",
@@ -308,9 +308,7 @@ const translations = {
complete: "Önerilerimi Getir", complete: "Önerilerimi Getir",
step1: { step1: {
title: "Bilgisayarınızı ne için kullanmak istiyorsunuz?", title: "Bilgisayarınızı ne için kullanmak istiyorsunuz?",
description: "İhtiyaçlarınıza uygun en fazla 3 kategori seçin", description: "İhtiyaçlarınıza uygun kategorileri seçin"
selected: "{count} seçildi (max 3)",
all_selected: "Tüm {count} kategori seçildi"
}, },
step2: { step2: {
title: "İşletim sisteminizi seçin", title: "İşletim sisteminizi seçin",
+231 -380
View File
@@ -1,403 +1,254 @@
import { CategoryPreset } from "@/types/recommendations"; import { UserCategory } from "@/types/recommendations";
/** /**
* Curated package recommendations for each user category * Curated package recommendations for each platform and category
* These presets are used by the recommendation engine to suggest packages * Simple structure: Platform → Category → Package names
*
* Edit this file to add/remove packages for each platform/category combination
*/ */
export const RECOMMENDATION_PRESETS: CategoryPreset[] = [
{ type PlatformId = "windows" | "macos" | "ubuntu" | "debian" | "arch" | "fedora";
category: "development",
description: "Essential tools for software development", export const PACKAGE_PRESETS: Record<PlatformId, Record<UserCategory, string[]>> = {
packages: [ windows: {
{ development: [
packageName: "git", "git",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"], "code", // Visual Studio Code
priority: 10, "nodejs",
reason: "Version control system essential for all developers", "python",
experienceLevel: ["beginner", "intermediate", "advanced"], "docker-desktop",
}, "postman",
{ ],
packageName: "code", design: [
platforms: ["windows", "macos", "ubuntu", "debian"], "gimp",
priority: 9, "inkscape",
reason: "Visual Studio Code - Popular code editor", "blender",
experienceLevel: ["beginner", "intermediate", "advanced"], ],
}, multimedia: [
{ "vlc",
packageName: "visual-studio-code", "audacity",
platforms: ["arch", "fedora"], "obs-studio",
priority: 9, ],
reason: "Visual Studio Code - Popular code editor", "system-tools": [
experienceLevel: ["beginner", "intermediate", "advanced"], "7zip",
}, "powertoys",
{ "everything",
packageName: "nodejs", ],
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"], gaming: [
priority: 8, "steam",
reason: "JavaScript runtime for modern web development", "discord",
experienceLevel: ["beginner", "intermediate", "advanced"], ],
}, productivity: [
{ "notion",
packageName: "python3", "obsidian",
platforms: ["ubuntu", "debian", "arch", "fedora"], "slack",
priority: 8, ],
reason: "Python programming language", education: [
experienceLevel: ["beginner", "intermediate", "advanced"], "anki",
},
{
packageName: "python",
platforms: ["windows", "macos"],
priority: 8,
reason: "Python programming language",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
{
packageName: "docker",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
priority: 7,
reason: "Containerization platform for development",
experienceLevel: ["intermediate", "advanced"],
},
{
packageName: "curl",
platforms: ["ubuntu", "debian", "arch", "fedora", "macos"],
priority: 7,
reason: "Command-line tool for transferring data",
experienceLevel: ["intermediate", "advanced"],
},
{
packageName: "vim",
platforms: ["ubuntu", "debian", "arch", "fedora", "macos"],
priority: 6,
reason: "Powerful text editor",
experienceLevel: ["intermediate", "advanced"],
},
{
packageName: "postman",
platforms: ["windows", "macos", "ubuntu", "debian"],
priority: 6,
reason: "API development and testing tool",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
], ],
}, },
{
category: "design", macos: {
description: "Tools for graphic design, UI/UX, and creative work", development: [
packages: [ "git",
{ "code", // Visual Studio Code
packageName: "gimp", "nodejs",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"], "python",
priority: 9, "docker",
reason: "Free and open-source image editor", "postman",
experienceLevel: ["beginner", "intermediate", "advanced"], ],
}, design: [
{ "gimp",
packageName: "inkscape", "inkscape",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"], "blender",
priority: 8, ],
reason: "Professional vector graphics editor", multimedia: [
experienceLevel: ["beginner", "intermediate", "advanced"], "vlc",
}, "audacity",
{ "obs",
packageName: "blender", ],
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"], "system-tools": [
priority: 8, "rectangle",
reason: "3D creation suite", "the-unarchiver",
experienceLevel: ["intermediate", "advanced"], ],
}, gaming: [
{ "steam",
packageName: "figma", "discord",
platforms: ["windows", "macos"], ],
priority: 9, productivity: [
reason: "Collaborative interface design tool", "notion",
experienceLevel: ["beginner", "intermediate", "advanced"], "obsidian",
}, "slack",
{ ],
packageName: "krita", education: [
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"], "anki",
priority: 7,
reason: "Digital painting application",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
], ],
}, },
{
category: "multimedia", ubuntu: {
description: "Audio, video editing and media management tools", development: [
packages: [ "git",
{ "code", // Visual Studio Code
packageName: "vlc", "nodejs",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"], "python3",
priority: 10, "docker.io",
reason: "Versatile media player", "curl",
experienceLevel: ["beginner", "intermediate", "advanced"], ],
}, design: [
{ "gimp",
packageName: "obs-studio", "inkscape",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"], "blender",
priority: 9, ],
reason: "Video recording and live streaming", multimedia: [
experienceLevel: ["intermediate", "advanced"], "vlc",
}, "audacity",
{ "obs-studio",
packageName: "audacity", ],
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"], "system-tools": [
priority: 8, "htop",
reason: "Audio editing software", "neofetch",
experienceLevel: ["beginner", "intermediate", "advanced"], "tldr",
}, ],
{ gaming: [
packageName: "ffmpeg", "steam",
platforms: ["ubuntu", "debian", "arch", "fedora", "macos"], "discord",
priority: 8, ],
reason: "Complete multimedia framework", productivity: [
experienceLevel: ["intermediate", "advanced"], "libreoffice",
}, "thunderbird",
{ ],
packageName: "handbrake", education: [
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"], "anki",
priority: 7,
reason: "Video transcoder",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
{
packageName: "kdenlive",
platforms: ["ubuntu", "debian", "arch", "fedora"],
priority: 7,
reason: "Video editing software",
experienceLevel: ["intermediate", "advanced"],
},
], ],
}, },
{
category: "system-tools", debian: {
description: "System administration, security and utilities", development: [
packages: [ "git",
{ "code",
packageName: "htop", "nodejs",
platforms: ["ubuntu", "debian", "arch", "fedora", "macos"], "python3",
priority: 9, "docker.io",
reason: "Interactive process viewer", "curl",
experienceLevel: ["beginner", "intermediate", "advanced"], ],
}, design: [
{ "gimp",
packageName: "tmux", "inkscape",
platforms: ["ubuntu", "debian", "arch", "fedora", "macos"], "blender",
priority: 8, ],
reason: "Terminal multiplexer", multimedia: [
experienceLevel: ["intermediate", "advanced"], "vlc",
}, "audacity",
{ "obs-studio",
packageName: "wget", ],
platforms: ["ubuntu", "debian", "arch", "fedora", "macos"], "system-tools": [
priority: 8, "htop",
reason: "Network downloader", "neofetch",
experienceLevel: ["beginner", "intermediate", "advanced"], "tldr",
}, ],
{ gaming: [
packageName: "neofetch", "steam",
platforms: ["ubuntu", "debian", "arch", "fedora", "macos"], "discord",
priority: 6, ],
reason: "System information tool", productivity: [
experienceLevel: ["beginner", "intermediate", "advanced"], "libreoffice",
}, "thunderbird",
{ ],
packageName: "wireshark", education: [
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"], "anki",
priority: 7,
reason: "Network protocol analyzer",
experienceLevel: ["advanced"],
},
{
packageName: "gparted",
platforms: ["ubuntu", "debian", "arch", "fedora"],
priority: 6,
reason: "Partition editor",
experienceLevel: ["intermediate", "advanced"],
},
], ],
}, },
{
category: "gaming", arch: {
description: "Gaming platforms and related tools", development: [
packages: [ "git",
{ "visual-studio-code-bin",
packageName: "steam", "nodejs",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"], "python",
priority: 10, "docker",
reason: "Gaming platform", "postman-bin",
experienceLevel: ["beginner", "intermediate", "advanced"], ],
}, design: [
{ "gimp",
packageName: "discord", "inkscape",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"], "blender",
priority: 9, ],
reason: "Voice and chat for gamers", multimedia: [
experienceLevel: ["beginner", "intermediate", "advanced"], "vlc",
}, "audacity",
{ "obs-studio",
packageName: "lutris", ],
platforms: ["ubuntu", "debian", "arch", "fedora"], "system-tools": [
priority: 7, "htop",
reason: "Open gaming platform", "neofetch",
experienceLevel: ["intermediate", "advanced"], "tldr",
}, ],
{ gaming: [
packageName: "wine", "steam",
platforms: ["ubuntu", "debian", "arch", "fedora", "macos"], "discord",
priority: 6, ],
reason: "Windows compatibility layer", productivity: [
experienceLevel: ["advanced"], "libreoffice-fresh",
}, "thunderbird",
],
education: [
"anki",
], ],
}, },
{
category: "productivity", fedora: {
description: "Office, note-taking and productivity tools", development: [
packages: [ "git",
{ "code",
packageName: "libreoffice", "nodejs",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"], "python3",
priority: 10, "docker",
reason: "Free office suite", "curl",
experienceLevel: ["beginner", "intermediate", "advanced"], ],
}, design: [
{ "gimp",
packageName: "thunderbird", "inkscape",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"], "blender",
priority: 8, ],
reason: "Email client", multimedia: [
experienceLevel: ["beginner", "intermediate", "advanced"], "vlc",
}, "audacity",
{ "obs-studio",
packageName: "notion", ],
platforms: ["windows", "macos"], "system-tools": [
priority: 9, "htop",
reason: "All-in-one workspace", "neofetch",
experienceLevel: ["beginner", "intermediate", "advanced"], "tldr",
}, ],
{ gaming: [
packageName: "obsidian", "steam",
platforms: ["windows", "macos", "ubuntu", "debian"], "discord",
priority: 8, ],
reason: "Knowledge base and note-taking", productivity: [
experienceLevel: ["intermediate", "advanced"], "libreoffice",
}, "thunderbird",
{ ],
packageName: "keepassxc", education: [
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"], "anki",
priority: 7,
reason: "Password manager",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
], ],
}, },
{ };
category: "education",
description: "Educational and scientific software",
packages: [
{
packageName: "anki",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
priority: 9,
reason: "Flashcard application for learning",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
{
packageName: "stellarium",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
priority: 7,
reason: "Planetarium software",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
{
packageName: "octave",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
priority: 7,
reason: "Scientific programming language",
experienceLevel: ["intermediate", "advanced"],
},
{
packageName: "geogebra",
platforms: ["windows", "macos", "ubuntu", "debian"],
priority: 8,
reason: "Interactive mathematics software",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
],
},
];
/** /**
* Get presets for specific categories * Get package names for a specific platform and categories
*/ */
export function getPresetsForCategories( export function getPackagesForPlatform(
categories: string[] platformId: string,
): CategoryPreset[] { categories: UserCategory[]
return RECOMMENDATION_PRESETS.filter((preset) =>
categories.includes(preset.category)
);
}
/**
* Get all package names from presets for a specific platform
*/
export function getPresetPackageNames(
categories: string[],
platformId: string
): string[] { ): string[] {
const presets = getPresetsForCategories(categories); const platform = PACKAGE_PRESETS[platformId as PlatformId];
const packageNames = new Set<string>(); if (!platform) return [];
presets.forEach((preset) => { const packages: string[] = [];
preset.packages.forEach((pkg) => { categories.forEach(category => {
if (pkg.platforms.includes(platformId)) { const categoryPackages = platform[category] || [];
packageNames.add(pkg.packageName); packages.push(...categoryPackages);
}
});
}); });
return Array.from(packageNames); return packages;
}
/**
* Get preset priority for a package
*/
export function getPresetPriority(
packageName: string,
categories: string[],
platformId: string
): number | null {
const presets = getPresetsForCategories(categories);
for (const preset of presets) {
const pkg = preset.packages.find(
(p) => p.packageName === packageName && p.platforms.includes(platformId)
);
if (pkg) {
return pkg.priority;
}
}
return null;
}
/**
* Get recommendation reason for a package
*/
export function getRecommendationReason(
packageName: string,
categories: string[]
): string | null {
const presets = getPresetsForCategories(categories);
for (const preset of presets) {
const pkg = preset.packages.find((p) => p.packageName === packageName);
if (pkg) {
return pkg.reason;
}
}
return null;
} }
+1 -9
View File
@@ -68,7 +68,6 @@ function getDefaultProfile(): UserProfile {
categories: [], categories: [],
detectedOS: detectOS(), detectedOS: detectOS(),
selectedOS: undefined, selectedOS: undefined,
experienceLevel: "beginner",
hasCompletedOnboarding: false, hasCompletedOnboarding: false,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
lastUpdated: new Date().toISOString(), lastUpdated: new Date().toISOString(),
@@ -166,13 +165,7 @@ export function useRecommendationProfile() {
[saveProfile] [saveProfile]
); );
// Update experience level
const updateExperienceLevel = useCallback(
(level: ExperienceLevel) => {
return saveProfile({ experienceLevel: level });
},
[saveProfile]
);
// Mark onboarding as completed // Mark onboarding as completed
const completeOnboarding = useCallback(() => { const completeOnboarding = useCallback(() => {
@@ -208,7 +201,6 @@ export function useRecommendationProfile() {
saveProfile, saveProfile,
updateCategories, updateCategories,
updateSelectedOS, updateSelectedOS,
updateExperienceLevel,
completeOnboarding, completeOnboarding,
resetProfile, resetProfile,
getEffectiveOS, getEffectiveOS,
+20 -185
View File
@@ -6,10 +6,7 @@ import {
UserCategory, UserCategory,
ExperienceLevel, ExperienceLevel,
} from "@/types/recommendations"; } from "@/types/recommendations";
import { import { getPackagesForPlatform } from "@/data/recommendationPresets";
getPresetPackageNames,
RECOMMENDATION_PRESETS,
} from "@/data/recommendationPresets";
@@ -23,50 +20,32 @@ export class RecommendationService {
const { platform_id, categories, experienceLevel, limit = 20 } = request; const { platform_id, categories, experienceLevel, limit = 20 } = request;
// 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 = getPackagesForPlatform(platform_id, categories);
// Step 2: Fetch packages from database with category tracking // Step 2: Fetch packages from database
// Map to track which category each package came from
const packageCategoryMap = new Map<string, UserCategory>(); const packageCategoryMap = new Map<string, UserCategory>();
// First, get preset packages (tagged with their categories) // Fetch preset packages
const presetPackagesWithCategories = const presetPackages = await this.fetchPresetPackages(
await this.fetchPresetPackagesWithCategories( presetPackageNames,
categories,
platform_id,
packageCategoryMap
);
// Then, get additional packages from categories
const categoryPackagesMap = await this.fetchCategoryPackagesWithTracking(
categories,
platform_id, platform_id,
limit * 2, // Fetch more to ensure we have enough after filtering categories,
packageCategoryMap packageCategoryMap
); );
// Step 3: Combine and deduplicate // Step 3: Score and rank packages
const allPackages = this.deduplicatePackages([ const scoredPackages = presetPackages.map((pkg) => {
...presetPackagesWithCategories, const matchedCategory = packageCategoryMap.get(pkg.id) || categories[0];
...categoryPackagesMap,
]);
// 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( return this.scorePackage(
pkg, pkg,
categories, categories,
platform_id, platform_id,
presetPackageNames, presetPackageNames,
experienceLevel,
matchedCategory matchedCategory
); );
}); });
// Step 5: Sort by score and limit results // Step 4: Sort by score and limit results
scoredPackages.sort( scoredPackages.sort(
(a, b) => b.recommendationScore - a.recommendationScore (a, b) => b.recommendationScore - a.recommendationScore
); );
@@ -74,71 +53,24 @@ 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
*/ */
private static async fetchPresetPackages( private static async fetchPresetPackages(
packageNames: string[], packageNames: string[],
platformId: string platformId: string,
categories: UserCategory[],
categoryMap: Map<string, UserCategory>
): Promise<Package[]> { ): Promise<Package[]> {
if (packageNames.length === 0) { if (packageNames.length === 0) {
return []; return [];
} }
try { try {
// Fetch all preset packages in one query
const packages: Package[] = []; const packages: Package[] = [];
let categoryIndex = 0;
// Search for each package name (case-insensitive) // Search for each package name (case-insensitive)
// Note: Current API doesn't support bulk name filtering,
// so we optimize by fetching larger batches and filtering
for (const name of packageNames) { for (const name of packageNames) {
const result = await PackageService.getMany({ const result = await PackageService.getMany({
platform_id: platformId, platform_id: platformId,
@@ -155,9 +87,14 @@ export class RecommendationService {
if (exactMatch) { if (exactMatch) {
packages.push(exactMatch); packages.push(exactMatch);
// Distribute categories evenly
categoryMap.set(exactMatch.id, categories[categoryIndex % categories.length]);
categoryIndex++;
} else if (result.packages.length > 0) { } else if (result.packages.length > 0) {
// If no exact match, take the first result (most popular match) // If no exact match, take the first result (most popular match)
packages.push(result.packages[0]); packages.push(result.packages[0]);
categoryMap.set(result.packages[0].id, categories[categoryIndex % categories.length]);
categoryIndex++;
} }
} }
@@ -168,105 +105,7 @@ 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
* Now properly uses database category filtering
*/
private static async fetchCategoryPackages(
categories: UserCategory[],
platformId: string,
limit: number
): Promise<Package[]> {
try {
// Map user categories to database category names (from schema.sql)
const categoryMap: Record<UserCategory, string[]> = {
development: ["Development", "Internet"],
design: ["Graphics"],
multimedia: ["Multimedia"],
"system-tools": ["System", "Utilities"],
gaming: ["Games"],
productivity: ["Office"],
education: ["Science"],
};
// Get category IDs from database
const allPackages: Package[] = [];
const seenIds = new Set<string>();
for (const category of categories) {
const dbCategoryNames = categoryMap[category] || [];
// Fetch packages for each DB category
for (const dbCategoryName of dbCategoryNames) {
// Note: We need to fetch by search since API doesn't expose category names directly
// This is a workaround until we add category name filtering to API
const result = await PackageService.getMany({
platform_id: platformId,
limit: Math.ceil(
limit / (categories.length * dbCategoryNames.length)
),
sort_by: "popularity_score",
sort_order: "desc",
});
// Add packages without duplicates
for (const pkg of result.packages) {
if (!seenIds.has(pkg.id)) {
seenIds.add(pkg.id);
allPackages.push(pkg);
}
}
}
}
return allPackages;
} catch (error) {
console.error("Error fetching category packages:", error);
return [];
}
}
/** /**
* Remove duplicate packages (by ID) * Remove duplicate packages (by ID)
@@ -282,9 +121,6 @@ export class RecommendationService {
}); });
} }
/**
* Score a package based on multiple factors
*/
/** /**
* Score a package based on simplified factors (popularity & preset) * Score a package based on simplified factors (popularity & preset)
*/ */
@@ -293,7 +129,6 @@ export class RecommendationService {
categories: UserCategory[], categories: UserCategory[],
platformId: string, platformId: string,
presetPackageNames: string[], presetPackageNames: string[],
experienceLevel?: ExperienceLevel,
matchedCategory?: UserCategory matchedCategory?: UserCategory
): RecommendedPackage { ): RecommendedPackage {
const isPresetMatch = presetPackageNames.includes(pkg.name); const isPresetMatch = presetPackageNames.includes(pkg.name);