feat: smart package recommendations with UX improvements

- Added smart recommendations section with category-based filtering
- Implemented onboarding flow with 3-step wizard (categories, OS, experience level)
- Added 'Select All/Deselect All' for recommendations
- Added 'Preferences' button in header to reopen onboarding
- Fixed script generation for recommendation-based packages (auto-detect platform)
- Fixed onboarding completion bug (removed duplicate save)
- Added comprehensive debug logging
- Enhanced ScriptPreview to work with auto-generated platform info
- Optimized recommendation API with proper validation and error handling
- Added localStorage profile management with version control
This commit is contained in:
ersaayan
2025-11-22 18:10:16 +03:00
parent a893ec6837
commit 32b415a97f
8 changed files with 182 additions and 29 deletions
+2 -1
View File
@@ -71,7 +71,8 @@ export async function POST(request: NextRequest) {
{ status: 400 } { status: 400 }
); );
} }
const limit = body.limit && body.limit > 0 && body.limit <= 50 ? body.limit : 20; const limit =
body.limit && body.limit > 0 && body.limit <= 50 ? body.limit : 20;
// Generate recommendations // Generate recommendations
const recommendations = await RecommendationService.generateRecommendations( const recommendations = await RecommendationService.generateRecommendations(
+19 -2
View File
@@ -5,14 +5,16 @@ import { Button } from '@/components/ui/button'
import { useTheme } from '@/hooks/useTheme' import { useTheme } from '@/hooks/useTheme'
import { useLocale } from '@/contexts/LocaleContext' import { useLocale } from '@/contexts/LocaleContext'
import { SupportModal } from '@/components/SupportModal' import { SupportModal } from '@/components/SupportModal'
import { Sun, Moon, Monitor, Globe, Heart, Github } from 'lucide-react' import { Sun, Moon, Monitor, Globe, Heart, Github, Settings } from 'lucide-react'
import Image from 'next/image' import Image from 'next/image'
export interface HeaderProps { export interface HeaderProps {
cryptomusEnabled: boolean cryptomusEnabled: boolean
onResetPreferences?: () => void
hasProfile?: boolean
} }
export function Header({ cryptomusEnabled }: HeaderProps) { export function Header({ cryptomusEnabled, onResetPreferences, hasProfile }: HeaderProps) {
const { theme, isDark, toggleTheme } = useTheme() const { theme, isDark, toggleTheme } = useTheme()
const { locale, toggleLocale, t } = useLocale() const { locale, toggleLocale, t } = useLocale()
const [isSupportModalOpen, setIsSupportModalOpen] = useState(false) const [isSupportModalOpen, setIsSupportModalOpen] = useState(false)
@@ -58,6 +60,21 @@ export function Header({ cryptomusEnabled }: HeaderProps) {
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{/* Preferences Button - Show when profile exists */}
{hasProfile && onResetPreferences && (
<Button
variant="ghost"
size="sm"
onClick={onResetPreferences}
className="w-full justify-start"
>
<Settings className="h-4 w-4" />
<span className="ml-2 hidden sm:inline">
{locale === 'tr' ? 'Tercihler' : 'Preferences'}
</span>
</Button>
)}
{/* Support Button - Only show when Cryptomus is enabled */} {/* Support Button - Only show when Cryptomus is enabled */}
{cryptomusEnabled && ( {cryptomusEnabled && (
<Button <Button
+19 -8
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { useState } from 'react' import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button' 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'
@@ -44,6 +44,15 @@ export function OnboardingModal({
) )
const [experienceLevel, setExperienceLevel] = useState<ExperienceLevel>('beginner') const [experienceLevel, setExperienceLevel] = useState<ExperienceLevel>('beginner')
// Reset state when modal opens
useEffect(() => {
if (isOpen) {
setStep(1)
// Keep current OS selection or use detected
setSelectedOS(detectedOS !== 'unknown' ? detectedOS : 'ubuntu')
}
}, [isOpen, detectedOS])
if (!isOpen) return null if (!isOpen) return null
const handleCategoryToggle = (category: UserCategory) => { const handleCategoryToggle = (category: UserCategory) => {
@@ -78,9 +87,11 @@ export function OnboardingModal({
onComplete({ onComplete({
categories: selectedCategories, categories: selectedCategories,
selectedOS: selectedOS !== detectedOS ? selectedOS : undefined, selectedOS: selectedOS,
experienceLevel experienceLevel
}) })
// Close modal
onClose() onClose()
} }
@@ -142,8 +153,8 @@ export function OnboardingModal({
key={preset.category} key={preset.category}
onClick={() => handleCategoryToggle(preset.category)} onClick={() => handleCategoryToggle(preset.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(preset.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-start gap-3">
@@ -192,8 +203,8 @@ export function OnboardingModal({
key={platform.id} key={platform.id}
onClick={() => setSelectedOS(platform.id)} onClick={() => setSelectedOS(platform.id)}
className={`p-4 rounded-lg border-2 text-center transition-all hover:scale-105 ${selectedOS === platform.id className={`p-4 rounded-lg border-2 text-center transition-all hover:scale-105 ${selectedOS === platform.id
? 'border-primary bg-primary/10' ? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50' : 'border-border hover:border-primary/50'
}`} }`}
> >
<div className="text-4xl mb-2">{platform.icon}</div> <div className="text-4xl mb-2">{platform.icon}</div>
@@ -222,8 +233,8 @@ export function OnboardingModal({
key={level} key={level}
onClick={() => setExperienceLevel(level)} onClick={() => setExperienceLevel(level)}
className={`w-full p-4 rounded-lg border-2 text-left transition-all hover:scale-[1.02] ${experienceLevel === 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-primary bg-primary/10'
: 'border-border hover:border-primary/50' : 'border-border hover:border-primary/50'
}`} }`}
> >
<h4 className="font-semibold capitalize mb-1"> <h4 className="font-semibold capitalize mb-1">
+33 -2
View File
@@ -50,7 +50,7 @@ export function RecommendationsSection({
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})) const errorData = await response.json().catch(() => ({}))
if (response.status === 400) { if (response.status === 400) {
throw new Error(errorData.error || 'Invalid request parameters') throw new Error(errorData.error || 'Invalid request parameters')
} else if (response.status === 500) { } else if (response.status === 500) {
@@ -66,7 +66,7 @@ export function RecommendationsSection({
setRecommendations(data.recommendations || []) setRecommendations(data.recommendations || [])
} catch (err) { } catch (err) {
console.error('Error fetching recommendations:', err) console.error('Error fetching recommendations:', err)
// User-friendly error messages // User-friendly error messages
if (err instanceof TypeError && err.message.includes('fetch')) { if (err instanceof TypeError && err.message.includes('fetch')) {
setError('Network error. Please check your internet connection.') setError('Network error. Please check your internet connection.')
@@ -109,6 +109,37 @@ export function RecommendationsSection({
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
// 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) {
// Deselect all recommendations
recommendations.forEach(rec => {
if (isPackageSelected(rec)) {
onPackageToggle(rec)
}
})
} else {
// Select all recommendations
recommendations.forEach(rec => {
if (!isPackageSelected(rec)) {
onPackageToggle(rec)
}
})
}
}}
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')}
</Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
+80 -7
View File
@@ -26,7 +26,6 @@ function RepoHubAppContent({ cryptomusEnabled }: { cryptomusEnabled: boolean })
profile, profile,
isLoading: isProfileLoading, isLoading: isProfileLoading,
hasCompletedOnboarding, hasCompletedOnboarding,
completeOnboarding,
saveProfile, saveProfile,
detectedOS detectedOS
} = useRecommendationProfile() } = useRecommendationProfile()
@@ -49,13 +48,22 @@ function RepoHubAppContent({ cryptomusEnabled }: { cryptomusEnabled: boolean })
selectedOS?: string selectedOS?: string
experienceLevel: ExperienceLevel experienceLevel: ExperienceLevel
}) => { }) => {
saveProfile({ console.log('🎯 Onboarding completed with data:', data)
const success = saveProfile({
categories: data.categories, categories: data.categories,
selectedOS: data.selectedOS, selectedOS: data.selectedOS,
experienceLevel: data.experienceLevel, experienceLevel: data.experienceLevel,
hasCompletedOnboarding: true hasCompletedOnboarding: true
}) })
completeOnboarding()
console.log('💾 Profile save result:', success)
// Don't call completeOnboarding() - it causes a second save with empty state!
// completeOnboarding()
// Force close modal
setShowOnboarding(false)
} }
const handleCustomizePreferences = () => { const handleCustomizePreferences = () => {
@@ -89,9 +97,64 @@ function RepoHubAppContent({ cryptomusEnabled }: { cryptomusEnabled: boolean })
} }
const handleGenerateScript = () => { const handleGenerateScript = () => {
if (selectedPlatform && selectedPackages.length > 0) { console.log('🔧 handleGenerateScript called')
const script = generateScript(selectedPackages, selectedPlatform) console.log('📦 selectedPackages:', selectedPackages)
console.log('🖥️ selectedPlatform:', selectedPlatform)
console.log('✅ hasCompletedOnboarding:', hasCompletedOnboarding)
if (selectedPackages.length === 0) {
console.log('❌ No packages selected')
return
}
// Use selected platform, or if not selected, use the platform from recommendations profile
let platformToUse = selectedPlatform
if (!platformToUse && hasCompletedOnboarding) {
// Get effective OS from profile and find matching platform
const effectiveOS = profile.selectedOS || detectedOS
console.log('🔍 effectiveOS:', effectiveOS)
// We need to fetch the platform data - for now, create a mock platform
// This should ideally come from the platforms list
if (effectiveOS) {
platformToUse = {
id: effectiveOS,
name: effectiveOS.charAt(0).toUpperCase() + effectiveOS.slice(1),
description: '',
icon: '',
packageManager: getPackageManagerForOS(effectiveOS)
}
console.log('🎯 Created platform:', platformToUse)
}
}
if (platformToUse) {
console.log('✨ Generating script for platform:', platformToUse)
const script = generateScript(selectedPackages, platformToUse)
console.log('📝 Script generated:', script)
setGeneratedScript(script) setGeneratedScript(script)
} else {
console.log('❌ No platform available')
}
}
// Helper function to get package manager for OS
const getPackageManagerForOS = (os: string): string => {
switch (os.toLowerCase()) {
case 'windows':
return 'winget'
case 'macos':
return 'brew'
case 'ubuntu':
case 'debian':
return 'apt'
case 'fedora':
return 'dnf'
case 'arch':
return 'pacman'
default:
return 'unknown'
} }
} }
@@ -106,7 +169,11 @@ function RepoHubAppContent({ cryptomusEnabled }: { cryptomusEnabled: boolean })
return ( return (
<div key={locale} className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800"> <div key={locale} className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800">
<Header cryptomusEnabled={cryptomusEnabled} /> <Header
cryptomusEnabled={cryptomusEnabled}
onResetPreferences={handleCustomizePreferences}
hasProfile={hasCompletedOnboarding}
/>
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
{/* Header */} {/* Header */}
@@ -161,7 +228,13 @@ function RepoHubAppContent({ cryptomusEnabled }: { cryptomusEnabled: boolean })
<ScriptPreview <ScriptPreview
generatedScript={generatedScript} generatedScript={generatedScript}
selectedPackages={selectedPackages} selectedPackages={selectedPackages}
selectedPlatform={selectedPlatform} selectedPlatform={selectedPlatform || {
id: generatedScript.platform,
name: generatedScript.platform.charAt(0).toUpperCase() + generatedScript.platform.slice(1),
description: '',
icon: '',
packageManager: getPackageManagerForOS(generatedScript.platform)
}}
onClose={handleCloseScriptPreview} onClose={handleCloseScriptPreview}
/> />
)} )}
+10 -4
View File
@@ -12,7 +12,9 @@ const translations = {
description: "Simplify software installation across Linux, Windows, and macOS with official repositories", description: "Simplify software installation across Linux, Windows, and macOS with official repositories",
close: "Close", close: "Close",
next: "Next", next: "Next",
back: "Back" back: "Back",
select_all: "Select All",
deselect_all: "Deselect All"
}, },
platform: { platform: {
select: "Select Your Platform", select: "Select Your Platform",
@@ -116,7 +118,8 @@ const translations = {
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 up to 3 categories that match your needs",
selected: "{count} selected (max 3)" selected: "{count} selected (max 3)",
all_selected: "All {count} categories selected"
}, },
step2: { step2: {
title: "Select your operating system", title: "Select your operating system",
@@ -194,7 +197,9 @@ const translations = {
description: "Linux, Windows ve macOS'te resmi depoları kullanarak yazılım kurulumunu basitleştirin", description: "Linux, Windows ve macOS'te resmi depoları kullanarak yazılım kurulumunu basitleştirin",
close: "Kapat", close: "Kapat",
next: "İleri", next: "İleri",
back: "Geri" back: "Geri",
select_all: "Tümünü Seç",
deselect_all: "Seçimi Kaldır"
}, },
platform: { platform: {
select: "Platformunuzu Seçin", select: "Platformunuzu Seçin",
@@ -298,7 +303,8 @@ const translations = {
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 en fazla 3 kategori seçin",
selected: "{count} seçildi (max 3)" 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",
+14 -2
View File
@@ -91,7 +91,12 @@ export function useRecommendationProfile() {
// Handle version migration // Handle version migration
if (!parsed.version || parsed.version < CURRENT_PROFILE_VERSION) { if (!parsed.version || parsed.version < CURRENT_PROFILE_VERSION) {
console.log("Migrating profile from version", parsed.version || 0, "to", CURRENT_PROFILE_VERSION); console.log(
"Migrating profile from version",
parsed.version || 0,
"to",
CURRENT_PROFILE_VERSION
);
// Add migration logic here when schema changes in the future // Add migration logic here when schema changes in the future
parsed.version = CURRENT_PROFILE_VERSION; parsed.version = CURRENT_PROFILE_VERSION;
} }
@@ -125,13 +130,20 @@ export function useRecommendationProfile() {
const updated: UserProfile = { const updated: UserProfile = {
...profile, ...profile,
...newProfile, ...newProfile,
version: CURRENT_PROFILE_VERSION,
lastUpdated: new Date().toISOString(), lastUpdated: new Date().toISOString(),
}; };
console.log("💾 Saving profile:", updated);
setProfile(updated); setProfile(updated);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
console.log("✅ Profile saved successfully to localStorage");
return true; return true;
} catch (error) { } catch (error) {
console.error("Error saving user profile:", error); console.error("Error saving user profile:", error);
return false; return false;
} }
}, },
+5 -3
View File
@@ -88,7 +88,7 @@ export class RecommendationService {
try { try {
// Fetch all preset packages in one query // Fetch all preset packages in one query
const packages: Package[] = []; const packages: Package[] = [];
// Search for each package name (case-insensitive) // Search for each package name (case-insensitive)
// Note: Current API doesn't support bulk name filtering, // Note: Current API doesn't support bulk name filtering,
// so we optimize by fetching larger batches and filtering // so we optimize by fetching larger batches and filtering
@@ -105,7 +105,7 @@ export class RecommendationService {
const exactMatch = result.packages.find( const exactMatch = result.packages.find(
(pkg) => pkg.name.toLowerCase() === name.toLowerCase() (pkg) => pkg.name.toLowerCase() === name.toLowerCase()
); );
if (exactMatch) { if (exactMatch) {
packages.push(exactMatch); packages.push(exactMatch);
} else if (result.packages.length > 0) { } else if (result.packages.length > 0) {
@@ -155,7 +155,9 @@ export class RecommendationService {
// This is a workaround until we add category name filtering to API // This is a workaround until we add category name filtering to API
const result = await PackageService.getMany({ const result = await PackageService.getMany({
platform_id: platformId, platform_id: platformId,
limit: Math.ceil(limit / (categories.length * dbCategoryNames.length)), limit: Math.ceil(
limit / (categories.length * dbCategoryNames.length)
),
sort_by: "popularity_score", sort_by: "popularity_score",
sort_order: "desc", sort_order: "desc",
}); });