mirror of
https://github.com/yusufipk/RepoHub.git
synced 2026-09-11 18:46:07 +00:00
refactor: standardize string quotes and improve code readability across multiple files
- Updated string quotes from single to double in useRecommendationProfile.ts, client.ts, recommendationService.ts, and recommendations.ts for consistency. - Enhanced error handling and logging in API client and recommendation service. - Improved code structure and formatting for better readability and maintainability. - Ensured consistent use of semicolons and spacing throughout the codebase.
This commit is contained in:
+238
-242
@@ -10,270 +10,266 @@ import { useLocale } from '@/contexts/LocaleContext'
|
||||
import { RECOMMENDATION_PRESETS } from '@/data/recommendationPresets'
|
||||
|
||||
interface OnboardingModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onComplete: (data: {
|
||||
categories: UserCategory[]
|
||||
selectedOS?: string
|
||||
experienceLevel: ExperienceLevel
|
||||
}) => void
|
||||
detectedOS: string
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onComplete: (data: {
|
||||
categories: UserCategory[]
|
||||
selectedOS?: string
|
||||
experienceLevel: ExperienceLevel
|
||||
}) => void
|
||||
detectedOS: string
|
||||
}
|
||||
|
||||
const PLATFORMS = [
|
||||
{ id: 'windows', name: 'Windows', icon: '🪟' },
|
||||
{ id: 'macos', name: 'macOS', icon: '🍎' },
|
||||
{ id: 'ubuntu', name: 'Ubuntu', icon: '🐧' },
|
||||
{ id: 'debian', name: 'Debian', icon: '🐧' },
|
||||
{ id: 'arch', name: 'Arch Linux', icon: '🏛️' },
|
||||
{ id: 'fedora', name: 'Fedora', icon: '🎩' }
|
||||
{ id: 'windows', name: 'Windows', icon: '🪟' },
|
||||
{ id: 'macos', name: 'macOS', icon: '🍎' },
|
||||
{ id: 'ubuntu', name: 'Ubuntu', icon: '🐧' },
|
||||
{ id: 'debian', name: 'Debian', icon: '🐧' },
|
||||
{ id: 'arch', name: 'Arch Linux', icon: '🏛️' },
|
||||
{ id: 'fedora', name: 'Fedora', icon: '🎩' }
|
||||
]
|
||||
|
||||
export function OnboardingModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onComplete,
|
||||
detectedOS
|
||||
export function OnboardingModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onComplete,
|
||||
detectedOS
|
||||
}: OnboardingModalProps) {
|
||||
const { t } = useLocale()
|
||||
const [step, setStep] = useState(1)
|
||||
const [selectedCategories, setSelectedCategories] = useState<UserCategory[]>([])
|
||||
const [selectedOS, setSelectedOS] = useState<string>(detectedOS)
|
||||
const [experienceLevel, setExperienceLevel] = useState<ExperienceLevel>('beginner')
|
||||
const { t } = useLocale()
|
||||
const [step, setStep] = useState(1)
|
||||
const [selectedCategories, setSelectedCategories] = useState<UserCategory[]>([])
|
||||
const [selectedOS, setSelectedOS] = useState<string>(detectedOS)
|
||||
const [experienceLevel, setExperienceLevel] = useState<ExperienceLevel>('beginner')
|
||||
|
||||
if (!isOpen) return null
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleCategoryToggle = (category: UserCategory) => {
|
||||
setSelectedCategories(prev => {
|
||||
if (prev.includes(category)) {
|
||||
return prev.filter(c => c !== category)
|
||||
}
|
||||
// Limit to 3 categories
|
||||
if (prev.length >= 3) {
|
||||
return [...prev.slice(1), category]
|
||||
}
|
||||
return [...prev, category]
|
||||
})
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (step < 3) {
|
||||
setStep(step + 1)
|
||||
const handleCategoryToggle = (category: UserCategory) => {
|
||||
setSelectedCategories(prev => {
|
||||
if (prev.includes(category)) {
|
||||
return prev.filter(c => c !== category)
|
||||
}
|
||||
// Limit to 3 categories
|
||||
if (prev.length >= 3) {
|
||||
return [...prev.slice(1), category]
|
||||
}
|
||||
return [...prev, category]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (step > 1) {
|
||||
setStep(step - 1)
|
||||
const handleNext = () => {
|
||||
if (step < 3) {
|
||||
setStep(step + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleComplete = () => {
|
||||
if (selectedCategories.length === 0) {
|
||||
return
|
||||
const handleBack = () => {
|
||||
if (step > 1) {
|
||||
setStep(step - 1)
|
||||
}
|
||||
}
|
||||
|
||||
onComplete({
|
||||
categories: selectedCategories,
|
||||
selectedOS: selectedOS !== detectedOS ? selectedOS : undefined,
|
||||
experienceLevel
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
|
||||
const canProceed = () => {
|
||||
if (step === 1) return selectedCategories.length > 0
|
||||
if (step === 2) return selectedOS !== 'unknown'
|
||||
if (step === 3) return true
|
||||
return false
|
||||
}
|
||||
const handleComplete = () => {
|
||||
if (selectedCategories.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<Card className="w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<CardHeader className="relative">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-6 w-6 text-primary" />
|
||||
<CardTitle className="text-2xl">
|
||||
{t('onboarding.title')}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{t('onboarding.subtitle')}
|
||||
</CardDescription>
|
||||
|
||||
{/* Progress indicator */}
|
||||
<div className="flex gap-2 mt-4">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-2 flex-1 rounded-full transition-colors ${
|
||||
i <= step ? 'bg-primary' : 'bg-secondary'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardHeader>
|
||||
onComplete({
|
||||
categories: selectedCategories,
|
||||
selectedOS: selectedOS !== detectedOS ? selectedOS : undefined,
|
||||
experienceLevel
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Step 1: Categories */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{t('onboarding.step1.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t('onboarding.step1.description')}
|
||||
</p>
|
||||
</div>
|
||||
const canProceed = () => {
|
||||
if (step === 1) return selectedCategories.length > 0
|
||||
if (step === 2) return selectedOS !== 'unknown'
|
||||
if (step === 3) return true
|
||||
return false
|
||||
}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{RECOMMENDATION_PRESETS.map(preset => (
|
||||
<button
|
||||
key={preset.category}
|
||||
onClick={() => handleCategoryToggle(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-border hover:border-primary/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-3xl">{preset.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<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>
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<Card className="w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<CardHeader className="relative">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-6 w-6 text-primary" />
|
||||
<CardTitle className="text-2xl">
|
||||
{t('onboarding.title')}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<CardDescription>
|
||||
{t('onboarding.subtitle')}
|
||||
</CardDescription>
|
||||
|
||||
{selectedCategories.length > 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t('onboarding.step1.selected', { count: selectedCategories.length })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Progress indicator */}
|
||||
<div className="flex gap-2 mt-4">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-2 flex-1 rounded-full transition-colors ${i <= step ? 'bg-primary' : 'bg-secondary'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* Step 2: Operating System */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{t('onboarding.step2.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t('onboarding.step2.description')}
|
||||
</p>
|
||||
{detectedOS !== 'unknown' && (
|
||||
<p className="text-sm text-primary mb-4">
|
||||
{t('onboarding.step2.detected', { os: detectedOS })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Step 1: Categories */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{t('onboarding.step1.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t('onboarding.step1.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{PLATFORMS.map(platform => (
|
||||
<button
|
||||
key={platform.id}
|
||||
onClick={() => setSelectedOS(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-border hover:border-primary/50'
|
||||
}`}
|
||||
>
|
||||
<div className="text-4xl mb-2">{platform.icon}</div>
|
||||
<div className="font-semibold text-sm">{platform.name}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{RECOMMENDATION_PRESETS.map(preset => (
|
||||
<button
|
||||
key={preset.category}
|
||||
onClick={() => handleCategoryToggle(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-border hover:border-primary/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-3xl">{preset.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<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>
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
{selectedCategories.length > 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t('onboarding.step1.selected', { count: selectedCategories.length })}
|
||||
</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>
|
||||
)}
|
||||
{/* Step 2: Operating System */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{t('onboarding.step2.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t('onboarding.step2.description')}
|
||||
</p>
|
||||
{detectedOS !== 'unknown' && (
|
||||
<p className="text-sm text-primary mb-4">
|
||||
{t('onboarding.step2.detected', { os: detectedOS })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
{step > 1 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleBack}
|
||||
className="flex-1"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-2" />
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{step < 3 ? (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={!canProceed()}
|
||||
className="flex-1"
|
||||
>
|
||||
{t('common.next')}
|
||||
<ChevronRight className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleComplete}
|
||||
disabled={!canProceed()}
|
||||
className="flex-1"
|
||||
>
|
||||
{t('onboarding.complete')}
|
||||
<Sparkles className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{PLATFORMS.map(platform => (
|
||||
<button
|
||||
key={platform.id}
|
||||
onClick={() => setSelectedOS(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-border hover:border-primary/50'
|
||||
}`}
|
||||
>
|
||||
<div className="text-4xl mb-2">{platform.icon}</div>
|
||||
<div className="font-semibold text-sm">{platform.name}</div>
|
||||
</button>
|
||||
))}
|
||||
</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 */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
{step > 1 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleBack}
|
||||
className="flex-1"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-2" />
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{step < 3 ? (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={!canProceed()}
|
||||
className="flex-1"
|
||||
>
|
||||
{t('common.next')}
|
||||
<ChevronRight className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleComplete}
|
||||
disabled={!canProceed()}
|
||||
className="flex-1"
|
||||
>
|
||||
{t('onboarding.complete')}
|
||||
<Sparkles className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,232 +10,231 @@ import { useLocale } from '@/contexts/LocaleContext'
|
||||
import { useRecommendationProfile } from '@/hooks/useRecommendationProfile'
|
||||
|
||||
interface RecommendationsSectionProps {
|
||||
onPackageToggle: (pkg: Package) => void
|
||||
selectedPackages: Package[]
|
||||
onCustomizeClick: () => void
|
||||
onPackageToggle: (pkg: Package) => void
|
||||
selectedPackages: Package[]
|
||||
onCustomizeClick: () => void
|
||||
}
|
||||
|
||||
export function RecommendationsSection({
|
||||
onPackageToggle,
|
||||
selectedPackages,
|
||||
onCustomizeClick
|
||||
onPackageToggle,
|
||||
selectedPackages,
|
||||
onCustomizeClick
|
||||
}: RecommendationsSectionProps) {
|
||||
const { t } = useLocale()
|
||||
const { profile, getEffectiveOS, isProfileComplete } = useRecommendationProfile()
|
||||
const [recommendations, setRecommendations] = useState<RecommendedPackage[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { t } = useLocale()
|
||||
const { profile, getEffectiveOS, isProfileComplete } = useRecommendationProfile()
|
||||
const [recommendations, setRecommendations] = useState<RecommendedPackage[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchRecommendations = async () => {
|
||||
if (!isProfileComplete()) {
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/recommendations', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
platform_id: getEffectiveOS(),
|
||||
categories: profile.categories,
|
||||
experienceLevel: profile.experienceLevel,
|
||||
limit: 12
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch recommendations')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setRecommendations(data.recommendations || [])
|
||||
} catch (err) {
|
||||
console.error('Error fetching recommendations:', err)
|
||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch recommendations on mount and when profile changes
|
||||
useEffect(() => {
|
||||
if (isProfileComplete()) {
|
||||
fetchRecommendations()
|
||||
}
|
||||
}, [profile.categories, profile.selectedOS, profile.experienceLevel])
|
||||
|
||||
const isPackageSelected = (pkg: RecommendedPackage) => {
|
||||
return selectedPackages.some(selected => selected.id === pkg.id)
|
||||
}
|
||||
|
||||
const fetchRecommendations = async () => {
|
||||
if (!isProfileComplete()) {
|
||||
return
|
||||
return null
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<CardTitle className="text-lg">
|
||||
{t('recommendations.title')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
{t('recommendations.subtitle')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchRecommendations}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
{t('recommendations.refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCustomizeClick}
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
{t('recommendations.customize')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/recommendations', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
platform_id: getEffectiveOS(),
|
||||
categories: profile.categories,
|
||||
experienceLevel: profile.experienceLevel,
|
||||
limit: 12
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch recommendations')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setRecommendations(data.recommendations || [])
|
||||
} catch (err) {
|
||||
console.error('Error fetching recommendations:', err)
|
||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch recommendations on mount and when profile changes
|
||||
useEffect(() => {
|
||||
if (isProfileComplete()) {
|
||||
fetchRecommendations()
|
||||
}
|
||||
}, [profile.categories, profile.selectedOS, profile.experienceLevel])
|
||||
|
||||
const isPackageSelected = (pkg: RecommendedPackage) => {
|
||||
return selectedPackages.some(selected => selected.id === pkg.id)
|
||||
}
|
||||
|
||||
if (!isProfileComplete()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<CardTitle className="text-lg">
|
||||
{t('recommendations.title')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
{t('recommendations.subtitle')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchRecommendations}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
{t('recommendations.refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCustomizeClick}
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
{t('recommendations.customize')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show user profile info */}
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
|
||||
{getEffectiveOS()}
|
||||
</span>
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<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 && (
|
||||
<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')}
|
||||
{/* Show user profile info */}
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
|
||||
{getEffectiveOS()}
|
||||
</span>
|
||||
</div>
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{error && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-destructive mb-4">{error}</p>
|
||||
<Button onClick={fetchRecommendations} variant="outline">
|
||||
Try Again
|
||||
</Button>
|
||||
</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}
|
||||
{!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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={onCustomizeClick} variant="outline">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
{t('recommendations.customize')}
|
||||
</Button>
|
||||
</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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
{!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 && (
|
||||
<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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ function RepoHubAppContent({ cryptomusEnabled }: { cryptomusEnabled: boolean })
|
||||
const [selectedPlatform, setSelectedPlatform] = useState<Platform | null>(null)
|
||||
const [selectedPackages, setSelectedPackages] = useState<SelectedPackage[]>([])
|
||||
const [generatedScript, setGeneratedScript] = useState<GeneratedScript | null>(null)
|
||||
|
||||
|
||||
// Recommendation profile management
|
||||
const {
|
||||
profile,
|
||||
@@ -30,7 +30,7 @@ function RepoHubAppContent({ cryptomusEnabled }: { cryptomusEnabled: boolean })
|
||||
saveProfile,
|
||||
detectedOS
|
||||
} = useRecommendationProfile()
|
||||
|
||||
|
||||
const [showOnboarding, setShowOnboarding] = useState(false)
|
||||
|
||||
// Show onboarding modal on first visit
|
||||
|
||||
Reference in New Issue
Block a user