mirror of
https://github.com/yusufipk/RepoHub.git
synced 2026-09-11 10:36:07 +00:00
Merge pull request #1 from ersaayan/feature/smart-package-recommendations
feat: Smart Package Recommendations System
This commit is contained in:
@@ -0,0 +1,264 @@
|
|||||||
|
# Smart Package Recommendations Feature
|
||||||
|
|
||||||
|
## 📋 Overview
|
||||||
|
|
||||||
|
This feature adds intelligent package recommendations to RepoHub based on user preferences, operating system, and experience level. It provides a personalized onboarding experience and curated package suggestions.
|
||||||
|
|
||||||
|
## ✨ Key Features
|
||||||
|
|
||||||
|
### 1. **Onboarding Modal**
|
||||||
|
- 3-step wizard for new users
|
||||||
|
- Category selection (up to 3 categories)
|
||||||
|
- OS detection with manual override option
|
||||||
|
- **Auto-fallback to Ubuntu** when OS detection fails
|
||||||
|
- Experience level selection (beginner/intermediate/advanced)
|
||||||
|
- Persistent localStorage-based profile with **version control**
|
||||||
|
|
||||||
|
### 2. **Smart Recommendations**
|
||||||
|
- Hybrid scoring algorithm:
|
||||||
|
- Category match: 40%
|
||||||
|
- Popularity: 30%
|
||||||
|
- OS compatibility: 20%
|
||||||
|
- Preset boost: 10%
|
||||||
|
- Curated preset packages for 7 categories
|
||||||
|
- Real-time filtering based on user profile
|
||||||
|
- **Optimized package fetching** (reduced N+1 queries)
|
||||||
|
- **Case-insensitive** package name matching
|
||||||
|
|
||||||
|
### 3. **Categories Supported**
|
||||||
|
- 💻 **Development**: IDEs, version control, programming languages
|
||||||
|
- 🎨 **Design**: Graphics editors, 3D tools, UI/UX software
|
||||||
|
- 🎬 **Multimedia**: Video/audio editing, media players
|
||||||
|
- ⚙️ **System Tools**: Admin tools, utilities, monitoring
|
||||||
|
- 🎮 **Gaming**: Game platforms, communication tools
|
||||||
|
- 📝 **Productivity**: Office suites, note-taking, password managers
|
||||||
|
- 🎓 **Education**: Learning tools, scientific software
|
||||||
|
|
||||||
|
## 🏗️ Architecture
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── types/
|
||||||
|
│ └── recommendations.ts # Type definitions
|
||||||
|
├── data/
|
||||||
|
│ └── recommendationPresets.ts # Hardcoded package presets
|
||||||
|
├── services/
|
||||||
|
│ └── recommendationService.ts # Recommendation algorithm
|
||||||
|
├── hooks/
|
||||||
|
│ └── useRecommendationProfile.ts # localStorage management + OS detection
|
||||||
|
├── components/
|
||||||
|
│ ├── OnboardingModal.tsx # User onboarding wizard
|
||||||
|
│ └── RecommendationsSection.tsx # Recommendation display
|
||||||
|
└── app/api/
|
||||||
|
└── recommendations/
|
||||||
|
└── route.ts # API endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. First Visit
|
||||||
|
└→ useRecommendationProfile detects !hasCompletedOnboarding
|
||||||
|
└→ OnboardingModal opens automatically
|
||||||
|
└→ User selects categories, OS, experience level
|
||||||
|
└→ Profile saved to localStorage
|
||||||
|
└→ hasCompletedOnboarding = true
|
||||||
|
|
||||||
|
2. Recommendations
|
||||||
|
└→ RecommendationsSection fetches from /api/recommendations
|
||||||
|
└→ POST { platform_id, categories, experienceLevel }
|
||||||
|
└→ RecommendationService.generateRecommendations()
|
||||||
|
└→ Fetch preset packages (exact name match)
|
||||||
|
└→ Fetch category packages (popularity-based)
|
||||||
|
└→ Score each package (hybrid algorithm)
|
||||||
|
└→ Return top 12 recommendations
|
||||||
|
|
||||||
|
3. User Actions
|
||||||
|
└→ Click "Customize Preferences" → Reopen OnboardingModal
|
||||||
|
└→ Click "Refresh Recommendations" → Refetch recommendations
|
||||||
|
└→ Click package card → Add to selection
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 API Usage
|
||||||
|
|
||||||
|
### POST /api/recommendations
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"platform_id": "ubuntu",
|
||||||
|
"categories": ["development", "productivity"],
|
||||||
|
"experienceLevel": "intermediate",
|
||||||
|
"limit": 12
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
{
|
||||||
|
"id": "pkg-uuid",
|
||||||
|
"name": "git",
|
||||||
|
"description": "Version control system",
|
||||||
|
"version": "2.43.0",
|
||||||
|
"recommendationScore": 95,
|
||||||
|
"recommendationReason": "Version control system essential for all developers",
|
||||||
|
"presetMatch": true,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 12,
|
||||||
|
"userProfile": {
|
||||||
|
"categories": ["development", "productivity"],
|
||||||
|
"platform": "ubuntu",
|
||||||
|
"experienceLevel": "intermediate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/recommendations
|
||||||
|
|
||||||
|
Query parameters version (alternative to POST):
|
||||||
|
```
|
||||||
|
GET /api/recommendations?platform_id=ubuntu&categories=development,productivity&experience_level=intermediate&limit=12
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 UI/UX Features
|
||||||
|
|
||||||
|
### Onboarding Modal
|
||||||
|
- **Step 1**: Category selection with icons and descriptions
|
||||||
|
- **Step 2**: OS selection (auto-detected + manual override)
|
||||||
|
- **Step 3**: Experience level with detailed descriptions
|
||||||
|
- Progress indicator (3 dots)
|
||||||
|
- Back/Next navigation
|
||||||
|
- Validation (can't proceed without required selections)
|
||||||
|
|
||||||
|
### Recommendations Section
|
||||||
|
- Grid layout (responsive: 1/2/3 columns)
|
||||||
|
- Package cards with:
|
||||||
|
- "Essential" badge for preset matches
|
||||||
|
- Recommendation score (0-100%) with progress bar
|
||||||
|
- Recommendation reason
|
||||||
|
- Version info
|
||||||
|
- Add to selection button
|
||||||
|
- User profile pills (OS + categories)
|
||||||
|
- Refresh button
|
||||||
|
- Customize preferences button
|
||||||
|
|
||||||
|
## 🌍 i18n Support
|
||||||
|
|
||||||
|
Full English and Turkish translations for:
|
||||||
|
- Onboarding flow
|
||||||
|
- Category names and descriptions
|
||||||
|
- Experience levels
|
||||||
|
- Recommendation UI labels
|
||||||
|
- Button text
|
||||||
|
|
||||||
|
Translation keys:
|
||||||
|
- `onboarding.*`
|
||||||
|
- `categories.*`
|
||||||
|
- `recommendations.*`
|
||||||
|
|
||||||
|
## 💾 localStorage Schema
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Key: 'repohub_user_profile'
|
||||||
|
{
|
||||||
|
categories: ['development', 'productivity'],
|
||||||
|
detectedOS: 'ubuntu',
|
||||||
|
selectedOS?: 'arch', // Manual override
|
||||||
|
experienceLevel: 'intermediate',
|
||||||
|
hasCompletedOnboarding: true,
|
||||||
|
createdAt: '2025-11-22T10:00:00Z',
|
||||||
|
lastUpdated: '2025-11-22T12:30:00Z'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 Testing Checklist
|
||||||
|
|
||||||
|
- [ ] First visit triggers onboarding modal
|
||||||
|
- [ ] OS detection works correctly (Windows/macOS/Linux)
|
||||||
|
- [ ] Category selection validates (max 3)
|
||||||
|
- [ ] Profile persists across page reloads
|
||||||
|
- [ ] Recommendations update when profile changes
|
||||||
|
- [ ] Package cards are clickable and add to selection
|
||||||
|
- [ ] "Customize Preferences" reopens onboarding
|
||||||
|
- [ ] "Refresh Recommendations" fetches new data
|
||||||
|
- [ ] i18n works (EN/TR switching)
|
||||||
|
- [ ] Responsive design on mobile/tablet/desktop
|
||||||
|
- [ ] Loading states display correctly
|
||||||
|
- [ ] Error states handle gracefully
|
||||||
|
|
||||||
|
## 🔮 Future Enhancements
|
||||||
|
|
||||||
|
### Phase 2 (Post-MVP)
|
||||||
|
- [ ] Database storage for user profiles (optional account system)
|
||||||
|
- [ ] Community ratings for packages
|
||||||
|
- [ ] User feedback loop (like/dislike recommendations)
|
||||||
|
- [ ] A/B testing for algorithm weights
|
||||||
|
- [ ] Admin panel for managing presets
|
||||||
|
|
||||||
|
### Phase 3 (ML-Ready)
|
||||||
|
- [ ] Collaborative filtering
|
||||||
|
- [ ] Package co-occurrence analysis
|
||||||
|
- [ ] Time-based trending packages
|
||||||
|
- [ ] Machine learning model integration
|
||||||
|
|
||||||
|
## 📊 Scoring Algorithm Details
|
||||||
|
|
||||||
|
### Hybrid Scoring Formula
|
||||||
|
```typescript
|
||||||
|
score =
|
||||||
|
(category_match * 0.4) +
|
||||||
|
(popularity_score / 100 * 0.3) +
|
||||||
|
(os_compatibility * 0.2) +
|
||||||
|
(preset_priority / 10 * 0.1)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Category Match
|
||||||
|
- Preset package: 1.0 (perfect match)
|
||||||
|
- Non-preset package: 0.5 (generic match)
|
||||||
|
|
||||||
|
### Popularity Score
|
||||||
|
- Normalized from 0-100 (from database)
|
||||||
|
- Higher popularity = better recommendation
|
||||||
|
|
||||||
|
### OS Compatibility
|
||||||
|
- All packages from DB are compatible = 1.0
|
||||||
|
- Future: could penalize packages with known issues
|
||||||
|
|
||||||
|
### Preset Priority
|
||||||
|
- Range: 1-10 (defined in presets)
|
||||||
|
- Normalized to 0.1-1.0
|
||||||
|
- Only applies to preset packages
|
||||||
|
|
||||||
|
## 🚀 Deployment Notes
|
||||||
|
|
||||||
|
1. No database migrations required (uses existing schema)
|
||||||
|
2. No environment variables needed (feature is client-side first)
|
||||||
|
3. Compatible with existing API structure
|
||||||
|
4. Progressive enhancement (works without JS for basic browse)
|
||||||
|
|
||||||
|
## 📝 Code Quality
|
||||||
|
|
||||||
|
- ✅ TypeScript strict mode
|
||||||
|
- ✅ Full type coverage
|
||||||
|
- ✅ ESLint compliant
|
||||||
|
- ✅ Responsive design
|
||||||
|
- ✅ Accessibility (keyboard navigation)
|
||||||
|
- ✅ Error boundaries
|
||||||
|
- ✅ Loading states
|
||||||
|
|
||||||
|
## 🔗 Related Files
|
||||||
|
|
||||||
|
- Issue: https://github.com/yusufipk/RepoHub/issues/1
|
||||||
|
- Branch: `feature/smart-package-recommendations`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Developed by:** @ersaayan
|
||||||
|
**Date:** November 22, 2025
|
||||||
|
**Status:** ✅ Ready for Review
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { RecommendationService } from "@/services/recommendationService";
|
||||||
|
import { RecommendationRequest } from "@/types/recommendations";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body: RecommendationRequest = await request.json();
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!body.platform_id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "platform_id is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.categories || body.categories.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "At least one category is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate platform_id
|
||||||
|
const validPlatforms = [
|
||||||
|
"windows",
|
||||||
|
"macos",
|
||||||
|
"ubuntu",
|
||||||
|
"debian",
|
||||||
|
"arch",
|
||||||
|
"fedora",
|
||||||
|
];
|
||||||
|
if (!validPlatforms.includes(body.platform_id)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `Invalid platform_id. Must be one of: ${validPlatforms.join(
|
||||||
|
", "
|
||||||
|
)}`,
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate categories
|
||||||
|
const validCategories = [
|
||||||
|
"development",
|
||||||
|
"design",
|
||||||
|
"multimedia",
|
||||||
|
"system-tools",
|
||||||
|
"gaming",
|
||||||
|
"productivity",
|
||||||
|
"education",
|
||||||
|
];
|
||||||
|
const invalidCategories = body.categories.filter(
|
||||||
|
(cat) => !validCategories.includes(cat)
|
||||||
|
);
|
||||||
|
if (invalidCategories.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `Invalid categories: ${invalidCategories.join(", ")}`,
|
||||||
|
validCategories,
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate and set limit with better error message
|
||||||
|
if (body.limit !== undefined && (body.limit < 1 || body.limit > 50)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Limit must be between 1 and 50" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const limit =
|
||||||
|
body.limit && body.limit > 0 && body.limit <= 50 ? body.limit : 20;
|
||||||
|
|
||||||
|
// Generate recommendations
|
||||||
|
const recommendations = await RecommendationService.generateRecommendations(
|
||||||
|
{
|
||||||
|
platform_id: body.platform_id,
|
||||||
|
categories: body.categories,
|
||||||
|
experienceLevel: body.experienceLevel,
|
||||||
|
limit,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
recommendations,
|
||||||
|
total: recommendations.length,
|
||||||
|
userProfile: {
|
||||||
|
categories: body.categories,
|
||||||
|
platform: body.platform_id,
|
||||||
|
experienceLevel: body.experienceLevel,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating recommendations:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "Failed to generate recommendations",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const platformId = searchParams.get("platform_id");
|
||||||
|
const categoriesParam = searchParams.get("categories");
|
||||||
|
const experienceLevel = searchParams.get("experience_level");
|
||||||
|
const limit = searchParams.get("limit");
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!platformId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "platform_id query parameter is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!categoriesParam) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "categories query parameter is required (comma-separated)" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse categories
|
||||||
|
const categories = categoriesParam.split(",").map((c) => c.trim());
|
||||||
|
|
||||||
|
// Validate platform_id
|
||||||
|
const validPlatforms = [
|
||||||
|
"windows",
|
||||||
|
"macos",
|
||||||
|
"ubuntu",
|
||||||
|
"debian",
|
||||||
|
"arch",
|
||||||
|
"fedora",
|
||||||
|
];
|
||||||
|
if (!validPlatforms.includes(platformId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `Invalid platform_id. Must be one of: ${validPlatforms.join(
|
||||||
|
", "
|
||||||
|
)}`,
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default limit
|
||||||
|
const parsedLimit =
|
||||||
|
limit && parseInt(limit) > 0 && parseInt(limit) <= 50
|
||||||
|
? parseInt(limit)
|
||||||
|
: 20;
|
||||||
|
|
||||||
|
// Generate recommendations
|
||||||
|
const recommendations = await RecommendationService.generateRecommendations(
|
||||||
|
{
|
||||||
|
platform_id: platformId,
|
||||||
|
categories: categories as any,
|
||||||
|
experienceLevel: experienceLevel as any,
|
||||||
|
limit: parsedLimit,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
recommendations,
|
||||||
|
total: recommendations.length,
|
||||||
|
userProfile: {
|
||||||
|
categories,
|
||||||
|
platform: platformId,
|
||||||
|
experienceLevel,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating recommendations:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "Failed to generate recommendations",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { X, ChevronRight, ChevronLeft, Sparkles } from 'lucide-react'
|
||||||
|
import { UserCategory, ExperienceLevel } from '@/types/recommendations'
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLATFORMS = [
|
||||||
|
{ id: 'windows', name: 'Windows' },
|
||||||
|
{ id: 'macos', name: 'macOS' },
|
||||||
|
{ id: 'ubuntu', name: 'Ubuntu' },
|
||||||
|
{ id: 'debian', name: 'Debian' },
|
||||||
|
{ id: 'arch', name: 'Arch Linux' },
|
||||||
|
{ 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({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onComplete,
|
||||||
|
detectedOS
|
||||||
|
}: OnboardingModalProps) {
|
||||||
|
const { t } = useLocale()
|
||||||
|
const [step, setStep] = useState(1)
|
||||||
|
const [selectedCategories, setSelectedCategories] = useState<UserCategory[]>([])
|
||||||
|
// Default to ubuntu if OS detection fails
|
||||||
|
const [selectedOS, setSelectedOS] = useState<string>(
|
||||||
|
detectedOS !== 'unknown' ? detectedOS : 'ubuntu'
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
|
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 handleBack = () => {
|
||||||
|
if (step > 1) {
|
||||||
|
setStep(step - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleComplete = () => {
|
||||||
|
if (selectedCategories.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onComplete({
|
||||||
|
categories: selectedCategories,
|
||||||
|
selectedOS: selectedOS,
|
||||||
|
experienceLevel
|
||||||
|
})
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const canProceed = () => {
|
||||||
|
if (step === 1) return selectedCategories.length > 0
|
||||||
|
if (step === 2) return selectedOS !== 'unknown'
|
||||||
|
if (step === 3) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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-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>
|
||||||
|
|
||||||
|
{selectedCategories.length > 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
{t('onboarding.step1.selected', { count: selectedCategories.length })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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={`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>
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,525 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Sparkles, RefreshCw, Settings, Package as PackageIcon, Star, Grid3x3, List, TrendingUp, Award, Info, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
|
import { RecommendedPackage } from '@/types/recommendations'
|
||||||
|
import { Package } from '@/types'
|
||||||
|
import { useLocale } from '@/contexts/LocaleContext'
|
||||||
|
import { useRecommendationProfile } from '@/hooks/useRecommendationProfile'
|
||||||
|
import { RECOMMENDATION_PRESETS } from '@/data/recommendationPresets'
|
||||||
|
|
||||||
|
interface RecommendationsSectionProps {
|
||||||
|
onPackageToggle: (pkg: Package) => void
|
||||||
|
selectedPackages: Package[]
|
||||||
|
onCustomizeClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type ViewMode = 'grid' | 'compact'
|
||||||
|
type SortMode = 'recommended' | 'popularity' | 'preset'
|
||||||
|
type FilterCategory = 'all' | string
|
||||||
|
|
||||||
|
export function RecommendationsSection({
|
||||||
|
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 [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 () => {
|
||||||
|
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) {
|
||||||
|
const errorData = await response.json().catch(() => ({}))
|
||||||
|
|
||||||
|
if (response.status === 400) {
|
||||||
|
throw new Error(errorData.error || 'Invalid request parameters')
|
||||||
|
} else if (response.status === 500) {
|
||||||
|
throw new Error('Server error. Please try again later.')
|
||||||
|
} else if (response.status === 404) {
|
||||||
|
throw new Error('Recommendation service not available')
|
||||||
|
} else {
|
||||||
|
throw new Error(`Request failed: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
setRecommendations(data.recommendations || [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching recommendations:', err)
|
||||||
|
|
||||||
|
// User-friendly error messages
|
||||||
|
if (err instanceof TypeError && err.message.includes('fetch')) {
|
||||||
|
setError('Network error. Please check your internet connection.')
|
||||||
|
} else {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load recommendations')
|
||||||
|
}
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full">
|
||||||
|
<CardHeader className="cursor-pointer hover:bg-accent/50 transition-colors" onClick={() => setIsExpanded(!isExpanded)}>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-5 w-5 text-primary" />
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CardTitle className="text-lg">
|
||||||
|
{t('recommendations.title')}
|
||||||
|
</CardTitle>
|
||||||
|
{!isExpanded && recommendations.length > 0 && (
|
||||||
|
<span className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
|
||||||
|
{recommendations.length} {t('recommendations.packages') || 'packages'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
{t('recommendations.subtitle')}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isExpanded && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
// Toggle: if all recommendations are selected, deselect them, otherwise select all
|
||||||
|
const allSelected = recommendations.every(rec =>
|
||||||
|
selectedPackages.some(sel => sel.id === rec.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (allSelected) {
|
||||||
|
// 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
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
fetchRecommendations()
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
{t('recommendations.refresh')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onCustomizeClick()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
|
{t('recommendations.customize')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Show user profile info */}
|
||||||
|
{isExpanded && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filters and View Controls */}
|
||||||
|
{isExpanded && !loading && !error && recommendations.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mt-4 pt-4 border-t">
|
||||||
|
{/* Category Filter Tabs */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant={filterCategory === 'all' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilterCategory('all')}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
>
|
||||||
|
All ({recommendations.length})
|
||||||
|
</Button>
|
||||||
|
{profile.categories.map(cat => {
|
||||||
|
const count = getCategoryCount(cat)
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={cat}
|
||||||
|
variant={filterCategory === cat ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilterCategory(cat)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
disabled={count === 0}
|
||||||
|
>
|
||||||
|
{getCategoryIcon(cat)} {t(`categories.${cat}.name`)} ({count})
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
|
{/* Sort Dropdown */}
|
||||||
|
<div className="flex items-center gap-1 border rounded-md">
|
||||||
|
<Button
|
||||||
|
variant={sortMode === 'recommended' ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSortMode('recommended')}
|
||||||
|
className="h-8 text-xs rounded-r-none"
|
||||||
|
>
|
||||||
|
<Award className="h-3 w-3 mr-1" />
|
||||||
|
{t('recommendations.sort.recommended') || 'Best Match'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={sortMode === 'popularity' ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSortMode('popularity')}
|
||||||
|
className="h-8 text-xs rounded-none border-x"
|
||||||
|
>
|
||||||
|
<TrendingUp className="h-3 w-3 mr-1" />
|
||||||
|
{t('recommendations.sort.popular') || 'Popular'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={sortMode === 'preset' ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSortMode('preset')}
|
||||||
|
className="h-8 text-xs rounded-l-none"
|
||||||
|
>
|
||||||
|
<Star className="h-3 w-3 mr-1" />
|
||||||
|
{t('recommendations.sort.preset') || 'Essential'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View Mode Toggle */}
|
||||||
|
<div className="flex items-center border rounded-md">
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'grid' ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode('grid')}
|
||||||
|
className="h-8 w-8 p-0 rounded-r-none"
|
||||||
|
>
|
||||||
|
<Grid3x3 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'compact' ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode('compact')}
|
||||||
|
className="h-8 w-8 p-0 rounded-l-none border-l"
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<CardContent>
|
||||||
|
{loading && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-4 text-primary" />
|
||||||
|
<p className="text-muted-foreground">{t('recommendations.loading')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-destructive mb-4">{error}</p>
|
||||||
|
<Button onClick={fetchRecommendations} variant="outline">
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && recommendations.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<PackageIcon className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
{t('recommendations.no_recommendations')}
|
||||||
|
</p>
|
||||||
|
<Button onClick={onCustomizeClick} variant="outline">
|
||||||
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
|
{t('recommendations.customize')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && recommendations.length > 0 && viewMode === 'grid' && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredAndSortedRecommendations.map(pkg => (
|
||||||
|
<Card
|
||||||
|
key={pkg.id}
|
||||||
|
className={`relative overflow-hidden transition-all hover:shadow-lg cursor-pointer ${isPackageSelected(pkg) ? 'ring-2 ring-primary' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => onPackageToggle(pkg)}
|
||||||
|
>
|
||||||
|
{pkg.presetMatch && (
|
||||||
|
<div className="absolute top-2 right-2">
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold bg-primary text-primary-foreground">
|
||||||
|
<Star className="h-3 w-3" />
|
||||||
|
{t('recommendations.preset_badge')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start gap-3 mb-3">
|
||||||
|
<PackageIcon className="h-8 w-8 text-primary flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold truncate">{pkg.name}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">{pkg.version}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">
|
||||||
|
{pkg.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Recommendation score and reason */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t('recommendations.score')}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="h-2 w-16 bg-secondary rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary rounded-full transition-all"
|
||||||
|
style={{ width: `${pkg.recommendationScore}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-semibold">{pkg.recommendationScore}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pkg.recommendationReason && (
|
||||||
|
<div className="pt-2 border-t">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
<span className="font-semibold">{t('recommendations.reason')}</span>
|
||||||
|
{' '}
|
||||||
|
{pkg.recommendationReason}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={isPackageSelected(pkg) ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="w-full mt-3"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onPackageToggle(pkg)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPackageSelected(pkg) ? '✓ Selected' : t('recommendations.add_to_selection')}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Compact View Mode */}
|
||||||
|
{!loading && !error && recommendations.length > 0 && viewMode === 'compact' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filteredAndSortedRecommendations.map(pkg => (
|
||||||
|
<div
|
||||||
|
key={pkg.id}
|
||||||
|
className={`flex items-center gap-3 p-3 rounded-lg border-2 transition-all cursor-pointer hover:shadow-md ${isPackageSelected(pkg)
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border hover:border-primary/50'
|
||||||
|
}`}
|
||||||
|
onClick={() => onPackageToggle(pkg)}
|
||||||
|
>
|
||||||
|
{/* Package Icon */}
|
||||||
|
<PackageIcon className="h-6 w-6 text-primary flex-shrink-0" />
|
||||||
|
|
||||||
|
{/* Package Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h4 className="font-semibold text-sm truncate">{pkg.name}</h4>
|
||||||
|
{pkg.presetMatch && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-semibold bg-primary text-primary-foreground">
|
||||||
|
<Star className="h-2.5 w-2.5" />
|
||||||
|
Essential
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-muted-foreground">{pkg.version}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{pkg.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Score Badge */}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-xs font-semibold text-primary">
|
||||||
|
{pkg.recommendationScore}%
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
match
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Tooltip */}
|
||||||
|
{pkg.recommendationReason && (
|
||||||
|
<div className="relative group">
|
||||||
|
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
|
||||||
|
<div className="absolute right-0 top-full mt-2 w-64 p-2 bg-popover text-popover-foreground text-xs rounded-md shadow-lg border opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10">
|
||||||
|
<p className="font-semibold mb-1">Why recommended?</p>
|
||||||
|
<p>{pkg.recommendationReason}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Select Button */}
|
||||||
|
<Button
|
||||||
|
variant={isPackageSelected(pkg) ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="ml-2"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onPackageToggle(pkg)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPackageSelected(pkg) ? '✓' : '+'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { LocaleProvider } from '@/contexts/LocaleContext'
|
import { LocaleProvider } from '@/contexts/LocaleContext'
|
||||||
import { Header } from './Header'
|
import { Header } from './Header'
|
||||||
import { PlatformSelector } from './PlatformSelector'
|
import { PlatformSelector } from './PlatformSelector'
|
||||||
import { PackageBrowserV2 } from './PackageBrowserV2'
|
import { PackageBrowserV2 } from './PackageBrowserV2'
|
||||||
import { SelectionManager } from './SelectionManager'
|
import { SelectionManager } from './SelectionManager'
|
||||||
import { ScriptPreview } from '@/components/ScriptPreview'
|
import { ScriptPreview } from '@/components/ScriptPreview'
|
||||||
|
import { OnboardingModal } from '@/components/OnboardingModal'
|
||||||
|
import { RecommendationsSection } from '@/components/RecommendationsSection'
|
||||||
import { generateScript } from '@/lib/scriptGenerator'
|
import { generateScript } from '@/lib/scriptGenerator'
|
||||||
import { useLocale } from '@/contexts/LocaleContext'
|
import { useLocale } from '@/contexts/LocaleContext'
|
||||||
|
import { useRecommendationProfile } from '@/hooks/useRecommendationProfile'
|
||||||
import { Platform, Package, SelectedPackage, FilterOptions, GeneratedScript } from '@/types'
|
import { Platform, Package, SelectedPackage, FilterOptions, GeneratedScript } from '@/types'
|
||||||
|
import { UserCategory, ExperienceLevel } from '@/types/recommendations'
|
||||||
|
|
||||||
function RepoHubAppContent({ cryptomusEnabled }: { cryptomusEnabled: boolean }) {
|
function RepoHubAppContent({ cryptomusEnabled }: { cryptomusEnabled: boolean }) {
|
||||||
const { t, locale } = useLocale()
|
const { t, locale } = useLocale()
|
||||||
@@ -17,6 +21,55 @@ function RepoHubAppContent({ cryptomusEnabled }: { cryptomusEnabled: boolean })
|
|||||||
const [selectedPackages, setSelectedPackages] = useState<SelectedPackage[]>([])
|
const [selectedPackages, setSelectedPackages] = useState<SelectedPackage[]>([])
|
||||||
const [generatedScript, setGeneratedScript] = useState<GeneratedScript | null>(null)
|
const [generatedScript, setGeneratedScript] = useState<GeneratedScript | null>(null)
|
||||||
|
|
||||||
|
// Recommendation profile management
|
||||||
|
const {
|
||||||
|
profile,
|
||||||
|
isLoading: isProfileLoading,
|
||||||
|
hasCompletedOnboarding,
|
||||||
|
saveProfile,
|
||||||
|
detectedOS
|
||||||
|
} = useRecommendationProfile()
|
||||||
|
|
||||||
|
const [showOnboarding, setShowOnboarding] = useState(false)
|
||||||
|
|
||||||
|
// Show onboarding modal on first visit
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isProfileLoading && !hasCompletedOnboarding) {
|
||||||
|
// Delay to allow page to render first
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setShowOnboarding(true)
|
||||||
|
}, 500)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [isProfileLoading, hasCompletedOnboarding])
|
||||||
|
|
||||||
|
const handleOnboardingComplete = (data: {
|
||||||
|
categories: UserCategory[]
|
||||||
|
selectedOS?: string
|
||||||
|
experienceLevel: ExperienceLevel
|
||||||
|
}) => {
|
||||||
|
console.log('🎯 Onboarding completed with data:', data)
|
||||||
|
|
||||||
|
const success = saveProfile({
|
||||||
|
categories: data.categories,
|
||||||
|
selectedOS: data.selectedOS,
|
||||||
|
experienceLevel: data.experienceLevel,
|
||||||
|
hasCompletedOnboarding: true
|
||||||
|
})
|
||||||
|
|
||||||
|
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 = () => {
|
||||||
|
setShowOnboarding(true)
|
||||||
|
}
|
||||||
|
|
||||||
const handlePlatformSelect = (platform: Platform) => {
|
const handlePlatformSelect = (platform: Platform) => {
|
||||||
setSelectedPlatform(platform)
|
setSelectedPlatform(platform)
|
||||||
// Clear selections when platform changes
|
// Clear selections when platform changes
|
||||||
@@ -44,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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,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 */}
|
||||||
@@ -79,6 +191,15 @@ function RepoHubAppContent({ cryptomusEnabled }: { cryptomusEnabled: boolean })
|
|||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
|
{/* Recommendations Section - Show if profile is complete */}
|
||||||
|
{hasCompletedOnboarding && profile.categories.length > 0 && (
|
||||||
|
<RecommendationsSection
|
||||||
|
onPackageToggle={handlePackageToggle}
|
||||||
|
selectedPackages={selectedPackages}
|
||||||
|
onCustomizeClick={handleCustomizePreferences}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Platform Selector */}
|
{/* Platform Selector */}
|
||||||
<PlatformSelector
|
<PlatformSelector
|
||||||
selectedPlatform={selectedPlatform}
|
selectedPlatform={selectedPlatform}
|
||||||
@@ -107,10 +228,24 @@ 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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Onboarding Modal */}
|
||||||
|
<OnboardingModal
|
||||||
|
isOpen={showOnboarding}
|
||||||
|
onClose={() => setShowOnboarding(false)}
|
||||||
|
onComplete={handleOnboardingComplete}
|
||||||
|
detectedOS={detectedOS || 'unknown'}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ const translations = {
|
|||||||
title: "RepoHub - Cross-Platform Package Manager",
|
title: "RepoHub - Cross-Platform Package Manager",
|
||||||
subtitle: "Cross-Platform Package Manager",
|
subtitle: "Cross-Platform Package Manager",
|
||||||
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",
|
||||||
|
back: "Back",
|
||||||
|
select_all: "Select All",
|
||||||
|
deselect_all: "Deselect All"
|
||||||
},
|
},
|
||||||
platform: {
|
platform: {
|
||||||
select: "Select Your Platform",
|
select: "Select Your Platform",
|
||||||
@@ -106,6 +110,90 @@ const translations = {
|
|||||||
success_note1: "Payment confirmation will be sent to your email if provided.",
|
success_note1: "Payment confirmation will be sent to your email if provided.",
|
||||||
success_note2: "You can close this page and return to RepoHub.",
|
success_note2: "You can close this page and return to RepoHub.",
|
||||||
back_to_site: "Back to RepoHub"
|
back_to_site: "Back to RepoHub"
|
||||||
|
},
|
||||||
|
onboarding: {
|
||||||
|
title: "Welcome to RepoHub!",
|
||||||
|
subtitle: "Let's personalize your package recommendations",
|
||||||
|
complete: "Get My Recommendations",
|
||||||
|
step1: {
|
||||||
|
title: "What do you want to use your computer for?",
|
||||||
|
description: "Select up to 3 categories that match your needs",
|
||||||
|
selected: "{count} selected (max 3)",
|
||||||
|
all_selected: "All {count} categories selected"
|
||||||
|
},
|
||||||
|
step2: {
|
||||||
|
title: "Select your operating system",
|
||||||
|
description: "We'll recommend compatible packages for your platform",
|
||||||
|
detected: "✓ Detected: {os}"
|
||||||
|
},
|
||||||
|
step3: {
|
||||||
|
title: "What's your experience level?",
|
||||||
|
description: "This helps us recommend appropriate tools",
|
||||||
|
levels: {
|
||||||
|
beginner: {
|
||||||
|
name: "Beginner",
|
||||||
|
description: "New to software installation and package management"
|
||||||
|
},
|
||||||
|
intermediate: {
|
||||||
|
name: "Intermediate",
|
||||||
|
description: "Comfortable with basic command-line operations"
|
||||||
|
},
|
||||||
|
advanced: {
|
||||||
|
name: "Advanced",
|
||||||
|
description: "Experienced with system administration and package management"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
categories: {
|
||||||
|
development: {
|
||||||
|
name: "Development",
|
||||||
|
description: "Code editors, compilers, and development tools"
|
||||||
|
},
|
||||||
|
design: {
|
||||||
|
name: "Design",
|
||||||
|
description: "Graphics, UI/UX, and creative software"
|
||||||
|
},
|
||||||
|
multimedia: {
|
||||||
|
name: "Multimedia",
|
||||||
|
description: "Audio, video editing, and media tools"
|
||||||
|
},
|
||||||
|
"system-tools": {
|
||||||
|
name: "System Tools",
|
||||||
|
description: "System administration and utilities"
|
||||||
|
},
|
||||||
|
gaming: {
|
||||||
|
name: "Gaming",
|
||||||
|
description: "Gaming platforms and related tools"
|
||||||
|
},
|
||||||
|
productivity: {
|
||||||
|
name: "Productivity",
|
||||||
|
description: "Office, note-taking, and productivity apps"
|
||||||
|
},
|
||||||
|
education: {
|
||||||
|
name: "Education",
|
||||||
|
description: "Educational and scientific software"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
recommendations: {
|
||||||
|
title: "Recommended for You",
|
||||||
|
subtitle: "Personalized package recommendations based on your profile",
|
||||||
|
refresh: "Refresh Recommendations",
|
||||||
|
customize: "Customize Preferences",
|
||||||
|
no_recommendations: "No recommendations available. Please update your preferences.",
|
||||||
|
loading: "Finding the best packages for you...",
|
||||||
|
score: "Match Score",
|
||||||
|
preset_badge: "Essential",
|
||||||
|
view_details: "View Details",
|
||||||
|
add_to_selection: "Add to Selection",
|
||||||
|
reason: "Why recommended:",
|
||||||
|
based_on: "Based on your interests in:",
|
||||||
|
packages: "packages",
|
||||||
|
sort: {
|
||||||
|
recommended: "Best Match",
|
||||||
|
popular: "Popular",
|
||||||
|
preset: "Essential"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
tr: {
|
tr: {
|
||||||
@@ -113,7 +201,11 @@ const translations = {
|
|||||||
title: "RepoHub - Çok Platformlu Paket Yöneticisi",
|
title: "RepoHub - Çok Platformlu Paket Yöneticisi",
|
||||||
subtitle: "Çok Platformlu Paket Yöneticisi",
|
subtitle: "Çok Platformlu Paket Yöneticisi",
|
||||||
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",
|
||||||
|
back: "Geri",
|
||||||
|
select_all: "Tümünü Seç",
|
||||||
|
deselect_all: "Seçimi Kaldır"
|
||||||
},
|
},
|
||||||
platform: {
|
platform: {
|
||||||
select: "Platformunuzu Seçin",
|
select: "Platformunuzu Seçin",
|
||||||
@@ -209,6 +301,90 @@ const translations = {
|
|||||||
success_note1: "Ödeme onayı sağlandıysa e-postanıza gönderilecektir.",
|
success_note1: "Ödeme onayı sağlandıysa e-postanıza gönderilecektir.",
|
||||||
success_note2: "Bu sayfayı kapatabilir ve RepoHub'a dönebilirsiniz.",
|
success_note2: "Bu sayfayı kapatabilir ve RepoHub'a dönebilirsiniz.",
|
||||||
back_to_site: "RepoHub'a Geri Dön"
|
back_to_site: "RepoHub'a Geri Dön"
|
||||||
|
},
|
||||||
|
onboarding: {
|
||||||
|
title: "RepoHub'a Hoş Geldiniz!",
|
||||||
|
subtitle: "Paket önerilerinizi kişiselleştirelim",
|
||||||
|
complete: "Önerilerimi Getir",
|
||||||
|
step1: {
|
||||||
|
title: "Bilgisayarınızı ne için kullanmak istiyorsunuz?",
|
||||||
|
description: "İhtiyaçlarınıza uygun en fazla 3 kategori seçin",
|
||||||
|
selected: "{count} seçildi (max 3)",
|
||||||
|
all_selected: "Tüm {count} kategori seçildi"
|
||||||
|
},
|
||||||
|
step2: {
|
||||||
|
title: "İşletim sisteminizi seçin",
|
||||||
|
description: "Platformunuzla uyumlu paketler önereceğiz",
|
||||||
|
detected: "✓ Tespit edildi: {os}"
|
||||||
|
},
|
||||||
|
step3: {
|
||||||
|
title: "Deneyim seviyeniz nedir?",
|
||||||
|
description: "Bu, size uygun araçları önermemize yardımcı olur",
|
||||||
|
levels: {
|
||||||
|
beginner: {
|
||||||
|
name: "Başlangıç",
|
||||||
|
description: "Yazılım kurulumu ve paket yönetimine yeni"
|
||||||
|
},
|
||||||
|
intermediate: {
|
||||||
|
name: "Orta",
|
||||||
|
description: "Temel komut satırı işlemlerinde rahat"
|
||||||
|
},
|
||||||
|
advanced: {
|
||||||
|
name: "İleri",
|
||||||
|
description: "Sistem yönetimi ve paket yönetiminde deneyimli"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
categories: {
|
||||||
|
development: {
|
||||||
|
name: "Geliştirme",
|
||||||
|
description: "Kod editörleri, derleyiciler ve geliştirme araçları"
|
||||||
|
},
|
||||||
|
design: {
|
||||||
|
name: "Tasarım",
|
||||||
|
description: "Grafik, UI/UX ve yaratıcı yazılımlar"
|
||||||
|
},
|
||||||
|
multimedia: {
|
||||||
|
name: "Multimedya",
|
||||||
|
description: "Ses, video düzenleme ve medya araçları"
|
||||||
|
},
|
||||||
|
"system-tools": {
|
||||||
|
name: "Sistem Araçları",
|
||||||
|
description: "Sistem yönetimi ve yardımcı programlar"
|
||||||
|
},
|
||||||
|
gaming: {
|
||||||
|
name: "Oyun",
|
||||||
|
description: "Oyun platformları ve ilgili araçlar"
|
||||||
|
},
|
||||||
|
productivity: {
|
||||||
|
name: "Üretkenlik",
|
||||||
|
description: "Ofis, not alma ve üretkenlik uygulamaları"
|
||||||
|
},
|
||||||
|
education: {
|
||||||
|
name: "Eğitim",
|
||||||
|
description: "Eğitim ve bilimsel yazılımlar"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
recommendations: {
|
||||||
|
title: "Size Özel Öneriler",
|
||||||
|
subtitle: "Profilinize göre kişiselleştirilmiş paket önerileri",
|
||||||
|
refresh: "Önerileri Yenile",
|
||||||
|
customize: "Tercihleri Özelleştir",
|
||||||
|
no_recommendations: "Öneri mevcut değil. Lütfen tercihlerinizi güncelleyin.",
|
||||||
|
loading: "Sizin için en iyi paketleri buluyoruz...",
|
||||||
|
score: "Eşleşme Skoru",
|
||||||
|
preset_badge: "Temel",
|
||||||
|
view_details: "Detayları Gör",
|
||||||
|
add_to_selection: "Seçime Ekle",
|
||||||
|
reason: "Neden önerildi:",
|
||||||
|
based_on: "İlgi alanlarınıza göre:",
|
||||||
|
packages: "paket",
|
||||||
|
sort: {
|
||||||
|
recommended: "En Uygun",
|
||||||
|
popular: "Popüler",
|
||||||
|
preset: "Temel"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,410 @@
|
|||||||
|
import { CategoryPreset } from "@/types/recommendations";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Curated package recommendations for each user category
|
||||||
|
* These presets are used by the recommendation engine to suggest packages
|
||||||
|
*/
|
||||||
|
export const RECOMMENDATION_PRESETS: CategoryPreset[] = [
|
||||||
|
{
|
||||||
|
category: "development",
|
||||||
|
description: "Essential tools for software development",
|
||||||
|
icon: "💻",
|
||||||
|
packages: [
|
||||||
|
{
|
||||||
|
packageName: "git",
|
||||||
|
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
|
||||||
|
priority: 10,
|
||||||
|
reason: "Version control system essential for all developers",
|
||||||
|
experienceLevel: ["beginner", "intermediate", "advanced"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
packageName: "code",
|
||||||
|
platforms: ["windows", "macos", "ubuntu", "debian"],
|
||||||
|
priority: 9,
|
||||||
|
reason: "Visual Studio Code - Popular code editor",
|
||||||
|
experienceLevel: ["beginner", "intermediate", "advanced"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
packageName: "visual-studio-code",
|
||||||
|
platforms: ["arch", "fedora"],
|
||||||
|
priority: 9,
|
||||||
|
reason: "Visual Studio Code - Popular code editor",
|
||||||
|
experienceLevel: ["beginner", "intermediate", "advanced"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
packageName: "nodejs",
|
||||||
|
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
|
||||||
|
priority: 8,
|
||||||
|
reason: "JavaScript runtime for modern web development",
|
||||||
|
experienceLevel: ["beginner", "intermediate", "advanced"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
packageName: "python3",
|
||||||
|
platforms: ["ubuntu", "debian", "arch", "fedora"],
|
||||||
|
priority: 8,
|
||||||
|
reason: "Python programming language",
|
||||||
|
experienceLevel: ["beginner", "intermediate", "advanced"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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",
|
||||||
|
description: "Tools for graphic design, UI/UX, and creative work",
|
||||||
|
icon: "🎨",
|
||||||
|
packages: [
|
||||||
|
{
|
||||||
|
packageName: "gimp",
|
||||||
|
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
|
||||||
|
priority: 9,
|
||||||
|
reason: "Free and open-source image editor",
|
||||||
|
experienceLevel: ["beginner", "intermediate", "advanced"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
packageName: "inkscape",
|
||||||
|
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
|
||||||
|
priority: 8,
|
||||||
|
reason: "Professional vector graphics editor",
|
||||||
|
experienceLevel: ["beginner", "intermediate", "advanced"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
packageName: "blender",
|
||||||
|
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
|
||||||
|
priority: 8,
|
||||||
|
reason: "3D creation suite",
|
||||||
|
experienceLevel: ["intermediate", "advanced"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
packageName: "figma",
|
||||||
|
platforms: ["windows", "macos"],
|
||||||
|
priority: 9,
|
||||||
|
reason: "Collaborative interface design tool",
|
||||||
|
experienceLevel: ["beginner", "intermediate", "advanced"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
packageName: "krita",
|
||||||
|
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
|
||||||
|
priority: 7,
|
||||||
|
reason: "Digital painting application",
|
||||||
|
experienceLevel: ["beginner", "intermediate", "advanced"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "multimedia",
|
||||||
|
description: "Audio, video editing and media management tools",
|
||||||
|
icon: "🎬",
|
||||||
|
packages: [
|
||||||
|
{
|
||||||
|
packageName: "vlc",
|
||||||
|
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
|
||||||
|
priority: 10,
|
||||||
|
reason: "Versatile media player",
|
||||||
|
experienceLevel: ["beginner", "intermediate", "advanced"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
packageName: "obs-studio",
|
||||||
|
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
|
||||||
|
priority: 9,
|
||||||
|
reason: "Video recording and live streaming",
|
||||||
|
experienceLevel: ["intermediate", "advanced"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
packageName: "audacity",
|
||||||
|
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
|
||||||
|
priority: 8,
|
||||||
|
reason: "Audio editing software",
|
||||||
|
experienceLevel: ["beginner", "intermediate", "advanced"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
packageName: "ffmpeg",
|
||||||
|
platforms: ["ubuntu", "debian", "arch", "fedora", "macos"],
|
||||||
|
priority: 8,
|
||||||
|
reason: "Complete multimedia framework",
|
||||||
|
experienceLevel: ["intermediate", "advanced"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
packageName: "handbrake",
|
||||||
|
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
|
||||||
|
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",
|
||||||
|
description: "System administration, security and utilities",
|
||||||
|
icon: "⚙️",
|
||||||
|
packages: [
|
||||||
|
{
|
||||||
|
packageName: "htop",
|
||||||
|
platforms: ["ubuntu", "debian", "arch", "fedora", "macos"],
|
||||||
|
priority: 9,
|
||||||
|
reason: "Interactive process viewer",
|
||||||
|
experienceLevel: ["beginner", "intermediate", "advanced"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
packageName: "tmux",
|
||||||
|
platforms: ["ubuntu", "debian", "arch", "fedora", "macos"],
|
||||||
|
priority: 8,
|
||||||
|
reason: "Terminal multiplexer",
|
||||||
|
experienceLevel: ["intermediate", "advanced"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
packageName: "wget",
|
||||||
|
platforms: ["ubuntu", "debian", "arch", "fedora", "macos"],
|
||||||
|
priority: 8,
|
||||||
|
reason: "Network downloader",
|
||||||
|
experienceLevel: ["beginner", "intermediate", "advanced"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
packageName: "neofetch",
|
||||||
|
platforms: ["ubuntu", "debian", "arch", "fedora", "macos"],
|
||||||
|
priority: 6,
|
||||||
|
reason: "System information tool",
|
||||||
|
experienceLevel: ["beginner", "intermediate", "advanced"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
packageName: "wireshark",
|
||||||
|
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
|
||||||
|
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",
|
||||||
|
description: "Gaming platforms and related tools",
|
||||||
|
icon: "🎮",
|
||||||
|
packages: [
|
||||||
|
{
|
||||||
|
packageName: "steam",
|
||||||
|
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
|
||||||
|
priority: 10,
|
||||||
|
reason: "Gaming platform",
|
||||||
|
experienceLevel: ["beginner", "intermediate", "advanced"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
packageName: "discord",
|
||||||
|
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
|
||||||
|
priority: 9,
|
||||||
|
reason: "Voice and chat for gamers",
|
||||||
|
experienceLevel: ["beginner", "intermediate", "advanced"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
packageName: "lutris",
|
||||||
|
platforms: ["ubuntu", "debian", "arch", "fedora"],
|
||||||
|
priority: 7,
|
||||||
|
reason: "Open gaming platform",
|
||||||
|
experienceLevel: ["intermediate", "advanced"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
packageName: "wine",
|
||||||
|
platforms: ["ubuntu", "debian", "arch", "fedora", "macos"],
|
||||||
|
priority: 6,
|
||||||
|
reason: "Windows compatibility layer",
|
||||||
|
experienceLevel: ["advanced"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "productivity",
|
||||||
|
description: "Office, note-taking and productivity tools",
|
||||||
|
icon: "📝",
|
||||||
|
packages: [
|
||||||
|
{
|
||||||
|
packageName: "libreoffice",
|
||||||
|
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
|
||||||
|
priority: 10,
|
||||||
|
reason: "Free office suite",
|
||||||
|
experienceLevel: ["beginner", "intermediate", "advanced"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
packageName: "thunderbird",
|
||||||
|
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
|
||||||
|
priority: 8,
|
||||||
|
reason: "Email client",
|
||||||
|
experienceLevel: ["beginner", "intermediate", "advanced"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
packageName: "notion",
|
||||||
|
platforms: ["windows", "macos"],
|
||||||
|
priority: 9,
|
||||||
|
reason: "All-in-one workspace",
|
||||||
|
experienceLevel: ["beginner", "intermediate", "advanced"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
packageName: "obsidian",
|
||||||
|
platforms: ["windows", "macos", "ubuntu", "debian"],
|
||||||
|
priority: 8,
|
||||||
|
reason: "Knowledge base and note-taking",
|
||||||
|
experienceLevel: ["intermediate", "advanced"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
packageName: "keepassxc",
|
||||||
|
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
|
||||||
|
priority: 7,
|
||||||
|
reason: "Password manager",
|
||||||
|
experienceLevel: ["beginner", "intermediate", "advanced"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "education",
|
||||||
|
description: "Educational and scientific software",
|
||||||
|
icon: "🎓",
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
export function getPresetsForCategories(
|
||||||
|
categories: string[]
|
||||||
|
): CategoryPreset[] {
|
||||||
|
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[] {
|
||||||
|
const presets = getPresetsForCategories(categories);
|
||||||
|
const packageNames = new Set<string>();
|
||||||
|
|
||||||
|
presets.forEach((preset) => {
|
||||||
|
preset.packages.forEach((pkg) => {
|
||||||
|
if (pkg.platforms.includes(platformId)) {
|
||||||
|
packageNames.add(pkg.packageName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(packageNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
UserProfile,
|
||||||
|
UserCategory,
|
||||||
|
ExperienceLevel,
|
||||||
|
} from "@/types/recommendations";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "repohub_user_profile";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect user's operating system from browser
|
||||||
|
*/
|
||||||
|
function detectOS(): string {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAgent = window.navigator.userAgent.toLowerCase();
|
||||||
|
const platform = window.navigator.platform.toLowerCase();
|
||||||
|
|
||||||
|
// Windows
|
||||||
|
if (userAgent.indexOf("win") !== -1 || platform.indexOf("win") !== -1) {
|
||||||
|
return "windows";
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS
|
||||||
|
if (
|
||||||
|
userAgent.indexOf("mac") !== -1 ||
|
||||||
|
platform.indexOf("mac") !== -1 ||
|
||||||
|
userAgent.indexOf("darwin") !== -1
|
||||||
|
) {
|
||||||
|
return "macos";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linux distros
|
||||||
|
if (userAgent.indexOf("linux") !== -1 || platform.indexOf("linux") !== -1) {
|
||||||
|
// Try to detect specific distro from user agent (rare but possible)
|
||||||
|
if (userAgent.indexOf("ubuntu") !== -1) {
|
||||||
|
return "ubuntu";
|
||||||
|
}
|
||||||
|
if (userAgent.indexOf("fedora") !== -1) {
|
||||||
|
return "fedora";
|
||||||
|
}
|
||||||
|
if (userAgent.indexOf("arch") !== -1) {
|
||||||
|
return "arch";
|
||||||
|
}
|
||||||
|
if (userAgent.indexOf("debian") !== -1) {
|
||||||
|
return "debian";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to Ubuntu for generic Linux
|
||||||
|
return "ubuntu";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
const CURRENT_PROFILE_VERSION = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default user profile
|
||||||
|
*/
|
||||||
|
function getDefaultProfile(): UserProfile {
|
||||||
|
return {
|
||||||
|
version: CURRENT_PROFILE_VERSION,
|
||||||
|
categories: [],
|
||||||
|
detectedOS: detectOS(),
|
||||||
|
selectedOS: undefined,
|
||||||
|
experienceLevel: "beginner",
|
||||||
|
hasCompletedOnboarding: false,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing user recommendation profile in localStorage
|
||||||
|
*/
|
||||||
|
export function useRecommendationProfile() {
|
||||||
|
const [profile, setProfile] = useState<UserProfile>(getDefaultProfile());
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Load profile from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored) as UserProfile;
|
||||||
|
|
||||||
|
// Handle version migration
|
||||||
|
if (!parsed.version || parsed.version < 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
|
||||||
|
parsed.version = CURRENT_PROFILE_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update detectedOS if it changed
|
||||||
|
const currentOS = detectOS();
|
||||||
|
if (parsed.detectedOS !== currentOS) {
|
||||||
|
parsed.detectedOS = currentOS;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProfile(parsed);
|
||||||
|
// Save migrated profile
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(parsed));
|
||||||
|
} else {
|
||||||
|
// First time user - save default profile
|
||||||
|
const defaultProfile = getDefaultProfile();
|
||||||
|
setProfile(defaultProfile);
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(defaultProfile));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading user profile:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save profile to localStorage
|
||||||
|
const saveProfile = useCallback(
|
||||||
|
(newProfile: Partial<UserProfile>) => {
|
||||||
|
try {
|
||||||
|
const updated: UserProfile = {
|
||||||
|
...profile,
|
||||||
|
...newProfile,
|
||||||
|
version: CURRENT_PROFILE_VERSION,
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("💾 Saving profile:", updated);
|
||||||
|
|
||||||
|
setProfile(updated);
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||||
|
|
||||||
|
console.log("✅ Profile saved successfully to localStorage");
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Error saving user profile:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[profile]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update categories
|
||||||
|
const updateCategories = useCallback(
|
||||||
|
(categories: UserCategory[]) => {
|
||||||
|
return saveProfile({ categories });
|
||||||
|
},
|
||||||
|
[saveProfile]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update selected OS (manual override)
|
||||||
|
const updateSelectedOS = useCallback(
|
||||||
|
(os: string) => {
|
||||||
|
return saveProfile({ selectedOS: os });
|
||||||
|
},
|
||||||
|
[saveProfile]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update experience level
|
||||||
|
const updateExperienceLevel = useCallback(
|
||||||
|
(level: ExperienceLevel) => {
|
||||||
|
return saveProfile({ experienceLevel: level });
|
||||||
|
},
|
||||||
|
[saveProfile]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark onboarding as completed
|
||||||
|
const completeOnboarding = useCallback(() => {
|
||||||
|
return saveProfile({ hasCompletedOnboarding: true });
|
||||||
|
}, [saveProfile]);
|
||||||
|
|
||||||
|
// Reset profile
|
||||||
|
const resetProfile = useCallback(() => {
|
||||||
|
try {
|
||||||
|
const defaultProfile = getDefaultProfile();
|
||||||
|
setProfile(defaultProfile);
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(defaultProfile));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error resetting user profile:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get effective OS (selectedOS or detectedOS)
|
||||||
|
const getEffectiveOS = useCallback((): string => {
|
||||||
|
return profile.selectedOS || profile.detectedOS || "ubuntu";
|
||||||
|
}, [profile]);
|
||||||
|
|
||||||
|
// Check if profile is complete enough for recommendations
|
||||||
|
const isProfileComplete = useCallback((): boolean => {
|
||||||
|
return profile.categories.length > 0 && getEffectiveOS() !== "unknown";
|
||||||
|
}, [profile, getEffectiveOS]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
profile,
|
||||||
|
isLoading,
|
||||||
|
saveProfile,
|
||||||
|
updateCategories,
|
||||||
|
updateSelectedOS,
|
||||||
|
updateExperienceLevel,
|
||||||
|
completeOnboarding,
|
||||||
|
resetProfile,
|
||||||
|
getEffectiveOS,
|
||||||
|
isProfileComplete,
|
||||||
|
detectedOS: profile.detectedOS,
|
||||||
|
hasCompletedOnboarding: profile.hasCompletedOnboarding,
|
||||||
|
};
|
||||||
|
}
|
||||||
+81
-46
@@ -1,92 +1,127 @@
|
|||||||
import { Platform, Package, FilterOptions } from '@/types'
|
import { Platform, Package, FilterOptions } from "@/types";
|
||||||
|
import {
|
||||||
|
RecommendationRequest,
|
||||||
|
RecommendationResponse,
|
||||||
|
} from "@/types/recommendations";
|
||||||
|
|
||||||
const API_BASE_URL = (process.env.NEXT_PUBLIC_API_URL && process.env.NEXT_PUBLIC_API_URL.trim() !== '')
|
const API_BASE_URL =
|
||||||
? process.env.NEXT_PUBLIC_API_URL.replace(/\/$/, '')
|
process.env.NEXT_PUBLIC_API_URL &&
|
||||||
: '/api'
|
process.env.NEXT_PUBLIC_API_URL.trim() !== ""
|
||||||
|
? process.env.NEXT_PUBLIC_API_URL.replace(/\/$/, "")
|
||||||
|
: "/api";
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
private async request<T>(
|
||||||
const url = `${API_BASE_URL}${endpoint}`
|
endpoint: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${API_BASE_URL}${endpoint}`;
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
...options.headers,
|
...options.headers,
|
||||||
},
|
},
|
||||||
...options,
|
...options,
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`API request failed: ${response.statusText}`)
|
throw new Error(`API request failed: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json()
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Platform operations
|
// Platform operations
|
||||||
async getPlatforms(): Promise<Platform[]> {
|
async getPlatforms(): Promise<Platform[]> {
|
||||||
return this.request<Platform[]>('/platforms')
|
return this.request<Platform[]>("/platforms");
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPlatform(id: string): Promise<Platform | null> {
|
async getPlatform(id: string): Promise<Platform | null> {
|
||||||
try {
|
try {
|
||||||
return await this.request<Platform>(`/platforms/${id}`)
|
return await this.request<Platform>(`/platforms/${id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Package operations
|
// Package operations
|
||||||
async getPackages(filters: FilterOptions = {}): Promise<{ packages: Package[], total: number }> {
|
async getPackages(
|
||||||
const params = new URLSearchParams()
|
filters: FilterOptions = {}
|
||||||
|
): Promise<{ packages: Package[]; total: number }> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
if (filters.platform_id) params.append('platform_id', filters.platform_id)
|
if (filters.platform_id) params.append("platform_id", filters.platform_id);
|
||||||
if (filters.category_id) params.append('category_id', filters.category_id.toString())
|
if (filters.category_id)
|
||||||
if (filters.type) params.append('type', filters.type)
|
params.append("category_id", filters.category_id.toString());
|
||||||
if (filters.repository) params.append('repository', filters.repository)
|
if (filters.type) params.append("type", filters.type);
|
||||||
if (filters.search) params.append('search', filters.search)
|
if (filters.repository) params.append("repository", filters.repository);
|
||||||
if (filters.limit) params.append('limit', filters.limit.toString())
|
if (filters.search) params.append("search", filters.search);
|
||||||
if (filters.offset) params.append('offset', filters.offset.toString())
|
if (filters.limit) params.append("limit", filters.limit.toString());
|
||||||
if (filters.sort_by) params.append('sort_by', filters.sort_by)
|
if (filters.offset) params.append("offset", filters.offset.toString());
|
||||||
if (filters.sort_order) params.append('sort_order', filters.sort_order)
|
if (filters.sort_by) params.append("sort_by", filters.sort_by);
|
||||||
|
if (filters.sort_order) params.append("sort_order", filters.sort_order);
|
||||||
|
|
||||||
const query = params.toString() ? `?${params.toString()}` : ''
|
const query = params.toString() ? `?${params.toString()}` : "";
|
||||||
return this.request<{ packages: Package[], total: number }>(`/packages${query}`)
|
return this.request<{ packages: Package[]; total: number }>(
|
||||||
|
`/packages${query}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPackage(id: string): Promise<Package | null> {
|
async getPackage(id: string): Promise<Package | null> {
|
||||||
try {
|
try {
|
||||||
return await this.request<Package>(`/packages/${id}`)
|
return await this.request<Package>(`/packages/${id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync operations
|
// Sync operations
|
||||||
async syncDebianPackages(): Promise<{ message: string, timestamp: string }> {
|
async syncDebianPackages(): Promise<{ message: string; timestamp: string }> {
|
||||||
return this.request<{ message: string, timestamp: string }>('/sync', {
|
return this.request<{ message: string; timestamp: string }>("/sync", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: JSON.stringify({ source: 'debian-official' }),
|
body: JSON.stringify({ source: "debian-official" }),
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async syncUbuntuPackages(): Promise<{ message: string, timestamp: string }> {
|
async syncUbuntuPackages(): Promise<{ message: string; timestamp: string }> {
|
||||||
return this.request<{ message: string, timestamp: string }>('/sync', {
|
return this.request<{ message: string; timestamp: string }>("/sync", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: JSON.stringify({ source: 'ubuntu-official' }),
|
body: JSON.stringify({ source: "ubuntu-official" }),
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async syncAllDebianPackages(): Promise<{ message: string, timestamp: string }> {
|
async syncAllDebianPackages(): Promise<{
|
||||||
return this.request<{ message: string, timestamp: string }>('/sync', {
|
message: string;
|
||||||
method: 'POST',
|
timestamp: string;
|
||||||
body: JSON.stringify({ source: 'all-official' }),
|
}> {
|
||||||
})
|
return this.request<{ message: string; timestamp: string }>("/sync", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ source: "all-official" }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSyncStatus(): Promise<{ status: string, last_sync: string | null, platforms: string[] }> {
|
async getSyncStatus(): Promise<{
|
||||||
return this.request<{ status: string, last_sync: string | null, platforms: string[] }>('/sync')
|
status: string;
|
||||||
|
last_sync: string | null;
|
||||||
|
platforms: string[];
|
||||||
|
}> {
|
||||||
|
return this.request<{
|
||||||
|
status: string;
|
||||||
|
last_sync: string | null;
|
||||||
|
platforms: string[];
|
||||||
|
}>("/sync");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recommendation operations
|
||||||
|
async getRecommendations(
|
||||||
|
request: RecommendationRequest
|
||||||
|
): Promise<RecommendationResponse> {
|
||||||
|
return this.request<RecommendationResponse>("/recommendations", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiClient = new ApiClient()
|
export const apiClient = new ApiClient();
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
import { PackageService } from "./packageService";
|
||||||
|
import { Package } from "@/models/Package";
|
||||||
|
import {
|
||||||
|
RecommendationRequest,
|
||||||
|
RecommendedPackage,
|
||||||
|
UserCategory,
|
||||||
|
ExperienceLevel,
|
||||||
|
} from "@/types/recommendations";
|
||||||
|
import {
|
||||||
|
getPresetPackageNames,
|
||||||
|
getPresetPriority,
|
||||||
|
getRecommendationReason,
|
||||||
|
RECOMMENDATION_PRESETS,
|
||||||
|
} from "@/data/recommendationPresets";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recommendation scoring weights
|
||||||
|
*/
|
||||||
|
const SCORING_WEIGHTS = {
|
||||||
|
CATEGORY_MATCH: 0.4,
|
||||||
|
POPULARITY: 0.3,
|
||||||
|
OS_COMPATIBILITY: 0.2,
|
||||||
|
PRESET_BOOST: 0.1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class RecommendationService {
|
||||||
|
/**
|
||||||
|
* Generate package recommendations based on user profile
|
||||||
|
*/
|
||||||
|
static async generateRecommendations(
|
||||||
|
request: RecommendationRequest
|
||||||
|
): Promise<RecommendedPackage[]> {
|
||||||
|
const { platform_id, categories, experienceLevel, limit = 20 } = request;
|
||||||
|
|
||||||
|
// Step 1: Get preset package names for the user's categories and platform
|
||||||
|
const presetPackageNames = getPresetPackageNames(categories, platform_id);
|
||||||
|
|
||||||
|
// Step 2: Fetch packages from database with category tracking
|
||||||
|
// Map to track which category each package came from
|
||||||
|
const packageCategoryMap = new Map<string, UserCategory>();
|
||||||
|
|
||||||
|
// First, get preset packages (tagged with their categories)
|
||||||
|
const presetPackagesWithCategories =
|
||||||
|
await this.fetchPresetPackagesWithCategories(
|
||||||
|
categories,
|
||||||
|
platform_id,
|
||||||
|
packageCategoryMap
|
||||||
|
);
|
||||||
|
|
||||||
|
// Then, get additional packages from categories
|
||||||
|
const categoryPackagesMap = await this.fetchCategoryPackagesWithTracking(
|
||||||
|
categories,
|
||||||
|
platform_id,
|
||||||
|
limit * 2, // Fetch more to ensure we have enough after filtering
|
||||||
|
packageCategoryMap
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 3: Combine and deduplicate
|
||||||
|
const allPackages = this.deduplicatePackages([
|
||||||
|
...presetPackagesWithCategories,
|
||||||
|
...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(
|
||||||
|
pkg,
|
||||||
|
categories,
|
||||||
|
platform_id,
|
||||||
|
presetPackageNames,
|
||||||
|
experienceLevel,
|
||||||
|
matchedCategory
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 5: Sort by score and limit results
|
||||||
|
scoredPackages.sort(
|
||||||
|
(a, b) => b.recommendationScore - a.recommendationScore
|
||||||
|
);
|
||||||
|
|
||||||
|
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
|
||||||
|
* Optimized: Uses single query instead of N queries
|
||||||
|
*/
|
||||||
|
private static async fetchPresetPackages(
|
||||||
|
packageNames: string[],
|
||||||
|
platformId: string
|
||||||
|
): Promise<Package[]> {
|
||||||
|
if (packageNames.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch all preset packages in one query
|
||||||
|
const packages: Package[] = [];
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
const result = await PackageService.getMany({
|
||||||
|
platform_id: platformId,
|
||||||
|
search: name,
|
||||||
|
limit: 5, // Get top 5 matches to handle variations
|
||||||
|
sort_by: "popularity_score",
|
||||||
|
sort_order: "desc",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find best match (case-insensitive, exact name preferred)
|
||||||
|
const exactMatch = result.packages.find(
|
||||||
|
(pkg) => pkg.name.toLowerCase() === name.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (exactMatch) {
|
||||||
|
packages.push(exactMatch);
|
||||||
|
} else if (result.packages.length > 0) {
|
||||||
|
// If no exact match, take the first result (most popular match)
|
||||||
|
packages.push(result.packages[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return packages;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching preset packages:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
*/
|
||||||
|
private static deduplicatePackages(packages: Package[]): Package[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return packages.filter((pkg) => {
|
||||||
|
if (seen.has(pkg.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(pkg.id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Score a package based on multiple factors
|
||||||
|
*/
|
||||||
|
private static scorePackage(
|
||||||
|
pkg: Package,
|
||||||
|
categories: UserCategory[],
|
||||||
|
platformId: string,
|
||||||
|
presetPackageNames: string[],
|
||||||
|
experienceLevel?: ExperienceLevel,
|
||||||
|
matchedCategory?: UserCategory
|
||||||
|
): RecommendedPackage {
|
||||||
|
let score = 0;
|
||||||
|
let reason = "";
|
||||||
|
const isPresetMatch = presetPackageNames.includes(pkg.name);
|
||||||
|
|
||||||
|
// 1. Category Match Score (40%)
|
||||||
|
// For preset packages, this is always high
|
||||||
|
const categoryScore = isPresetMatch ? 1.0 : 0.5;
|
||||||
|
score += categoryScore * SCORING_WEIGHTS.CATEGORY_MATCH;
|
||||||
|
|
||||||
|
// 2. Popularity Score (30%)
|
||||||
|
// Normalize popularity_score (0-100) to 0-1
|
||||||
|
const popularityScore = (pkg.popularity_score || 0) / 100;
|
||||||
|
score += popularityScore * SCORING_WEIGHTS.POPULARITY;
|
||||||
|
|
||||||
|
// 3. OS Compatibility Score (20%)
|
||||||
|
// All packages from DB should be compatible, so this is always 1.0
|
||||||
|
const osScore = 1.0;
|
||||||
|
score += osScore * SCORING_WEIGHTS.OS_COMPATIBILITY;
|
||||||
|
|
||||||
|
// 4. Preset Boost (10%)
|
||||||
|
// Extra boost for preset packages based on priority
|
||||||
|
let presetBoost = 0;
|
||||||
|
if (isPresetMatch) {
|
||||||
|
const priority = getPresetPriority(pkg.name, categories, platformId);
|
||||||
|
if (priority !== null) {
|
||||||
|
presetBoost = priority / 10; // Normalize 1-10 to 0.1-1.0
|
||||||
|
|
||||||
|
// Get recommendation reason from preset
|
||||||
|
const presetReason = getRecommendationReason(pkg.name, categories);
|
||||||
|
if (presetReason) {
|
||||||
|
reason = presetReason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
score += presetBoost * SCORING_WEIGHTS.PRESET_BOOST;
|
||||||
|
|
||||||
|
// Default reason if not from preset
|
||||||
|
if (!reason) {
|
||||||
|
if (pkg.popularity_score && pkg.popularity_score > 70) {
|
||||||
|
reason = "Popular choice in the community";
|
||||||
|
} else {
|
||||||
|
reason = "Recommended for your selected categories";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize final score to 0-100
|
||||||
|
const finalScore = Math.round(score * 100);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: pkg.id,
|
||||||
|
name: pkg.name,
|
||||||
|
description: pkg.description || "No description available",
|
||||||
|
version: pkg.version || "latest",
|
||||||
|
category:
|
||||||
|
typeof pkg.category === "string" ? pkg.category : pkg.category?.name,
|
||||||
|
license:
|
||||||
|
typeof pkg.license === "string" ? pkg.license : pkg.license?.name,
|
||||||
|
type: pkg.type || "cli",
|
||||||
|
platform: pkg.platform,
|
||||||
|
platform_id: pkg.platform_id,
|
||||||
|
repository: pkg.repository || "official",
|
||||||
|
download_url: pkg.download_url,
|
||||||
|
lastUpdated: pkg.last_updated ? pkg.last_updated.toString() : undefined,
|
||||||
|
downloads: pkg.downloads_count,
|
||||||
|
popularity: pkg.popularity_score,
|
||||||
|
popularity_score: pkg.popularity_score,
|
||||||
|
tags: pkg.tags,
|
||||||
|
recommendationScore: finalScore,
|
||||||
|
recommendationReason: reason,
|
||||||
|
presetMatch: isPresetMatch,
|
||||||
|
matchedCategory: matchedCategory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get quick start recommendations (top 5 most essential)
|
||||||
|
*/
|
||||||
|
static async getQuickStartRecommendations(
|
||||||
|
platformId: string,
|
||||||
|
primaryCategory: UserCategory
|
||||||
|
): Promise<RecommendedPackage[]> {
|
||||||
|
return this.generateRecommendations({
|
||||||
|
platform_id: platformId,
|
||||||
|
categories: [primaryCategory],
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recommendations for multiple categories with balanced distribution
|
||||||
|
*/
|
||||||
|
static async getBalancedRecommendations(
|
||||||
|
platformId: string,
|
||||||
|
categories: UserCategory[],
|
||||||
|
totalLimit: number = 20
|
||||||
|
): Promise<RecommendedPackage[]> {
|
||||||
|
const perCategory = Math.ceil(totalLimit / categories.length);
|
||||||
|
const allRecommendations: RecommendedPackage[] = [];
|
||||||
|
|
||||||
|
for (const category of categories) {
|
||||||
|
const recommendations = await this.generateRecommendations({
|
||||||
|
platform_id: platformId,
|
||||||
|
categories: [category],
|
||||||
|
limit: perCategory,
|
||||||
|
});
|
||||||
|
allRecommendations.push(...recommendations);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate by ID and re-sort
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const deduplicated = allRecommendations.filter((pkg) => {
|
||||||
|
if (seen.has(pkg.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(pkg.id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
deduplicated.sort((a, b) => b.recommendationScore - a.recommendationScore);
|
||||||
|
|
||||||
|
return deduplicated.slice(0, totalLimit);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { Package, Platform } from "./index";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User category types for package recommendations
|
||||||
|
*/
|
||||||
|
export type UserCategory =
|
||||||
|
| "development"
|
||||||
|
| "design"
|
||||||
|
| "multimedia"
|
||||||
|
| "system-tools"
|
||||||
|
| "gaming"
|
||||||
|
| "productivity"
|
||||||
|
| "education";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User experience level
|
||||||
|
*/
|
||||||
|
export type ExperienceLevel = "beginner" | "intermediate" | "advanced";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User profile stored in localStorage
|
||||||
|
*/
|
||||||
|
export interface UserProfile {
|
||||||
|
version: number; // Schema version for future migrations
|
||||||
|
categories: UserCategory[];
|
||||||
|
detectedOS?: string;
|
||||||
|
selectedOS?: string; // Manual override
|
||||||
|
experienceLevel?: ExperienceLevel;
|
||||||
|
hasCompletedOnboarding: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
lastUpdated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request payload for recommendation API
|
||||||
|
*/
|
||||||
|
export interface RecommendationRequest {
|
||||||
|
platform_id: string;
|
||||||
|
categories: UserCategory[];
|
||||||
|
experienceLevel?: ExperienceLevel;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recommended package with score
|
||||||
|
*/
|
||||||
|
export interface RecommendedPackage {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
version: string;
|
||||||
|
category?: string;
|
||||||
|
license?: string;
|
||||||
|
type: "gui" | "cli";
|
||||||
|
platform?: string | any;
|
||||||
|
platform_id?: string;
|
||||||
|
repository: "official" | "third-party" | "aur";
|
||||||
|
download_url?: string;
|
||||||
|
lastUpdated?: string;
|
||||||
|
downloads?: number;
|
||||||
|
popularity?: number;
|
||||||
|
popularity_score?: number;
|
||||||
|
tags?: string[];
|
||||||
|
recommendationScore: number;
|
||||||
|
recommendationReason: string;
|
||||||
|
presetMatch?: boolean;
|
||||||
|
matchedCategory?: UserCategory; // Which user category this package matched
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset package configuration
|
||||||
|
*/
|
||||||
|
export interface PackagePreset {
|
||||||
|
packageName: string;
|
||||||
|
platforms: string[]; // ['windows', 'macos', 'ubuntu', 'arch', 'fedora']
|
||||||
|
priority: number; // 1-10, higher = more important
|
||||||
|
reason: string; // Why this package is recommended
|
||||||
|
experienceLevel?: ExperienceLevel[]; // Target experience levels
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Category preset configuration
|
||||||
|
*/
|
||||||
|
export interface CategoryPreset {
|
||||||
|
category: UserCategory;
|
||||||
|
packages: PackagePreset[];
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recommendation response
|
||||||
|
*/
|
||||||
|
export interface RecommendationResponse {
|
||||||
|
recommendations: RecommendedPackage[];
|
||||||
|
total: number;
|
||||||
|
userProfile: {
|
||||||
|
categories: UserCategory[];
|
||||||
|
platform: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
+18
-4
@@ -1,7 +1,12 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es5",
|
"target": "es5",
|
||||||
"lib": ["dom", "dom.iterable", "es6"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"es6",
|
||||||
|
"es2017"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -20,9 +25,18 @@
|
|||||||
],
|
],
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": [
|
||||||
"exclude": ["node_modules"]
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user