Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -6,10 +6,14 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
@@ -24,14 +28,23 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"@vitest/ui": "^4.1.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"jsdom": "^29.0.1",
|
||||
"shadcn": "^3.8.5",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vite": "^8.0.1",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
7
frontend/src/__tests__/setup.ts
Normal file
7
frontend/src/__tests__/setup.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import { afterEach } from 'vitest';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
128
frontend/src/app/games/add/page.tsx
Normal file
128
frontend/src/app/games/add/page.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { EnrichedGame, metadataApi } from '@/lib/api';
|
||||
import { SearchForm } from '@/components/games/SearchForm';
|
||||
import { SearchResults } from '@/components/games/SearchResults';
|
||||
import { GamePreviewDialog } from '@/components/games/GamePreviewDialog';
|
||||
import Navbar from '@/components/landing/Navbar';
|
||||
|
||||
export default function AddGamePage() {
|
||||
const router = useRouter();
|
||||
const [searchResults, setSearchResults] = useState<EnrichedGame[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [selectedGame, setSelectedGame] = useState<EnrichedGame | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSearch = async (params: { title: string; platform?: string; year?: number }) => {
|
||||
setIsSearching(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const results = await metadataApi.searchGames(params);
|
||||
setSearchResults(results);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al buscar juegos');
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectResult = (game: EnrichedGame) => {
|
||||
setSelectedGame(game);
|
||||
};
|
||||
|
||||
const handleSaveGame = async (data: {
|
||||
metadata: EnrichedGame;
|
||||
overrides: { platform?: string; year?: number; description?: string };
|
||||
}) => {
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await metadataApi.createGameFromMetadata(data);
|
||||
setSelectedGame(null);
|
||||
router.push('/games');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al guardar el juego');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Starfield background */}
|
||||
<div className="starfield" />
|
||||
|
||||
{/* Navbar */}
|
||||
<Navbar />
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="relative z-10 pt-20">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-7xl mx-auto w-full">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-responsive-3xl font-bold mb-2">
|
||||
<span className="gradient-text">BUSCAR JUEGOS</span>
|
||||
</h1>
|
||||
<p className="text-muted-foreground mono text-sm tracking-wider">
|
||||
BUSCA EN PROVEEDORES EXTERNOS Y AÑADE JUEGOS A TU BIBLIOTECA
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<p className="text-destructive mono text-sm tracking-wider">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Form */}
|
||||
<div className="mb-8 p-6 bg-card border rounded-lg">
|
||||
<SearchForm onSearch={handleSearch} isLoading={isSearching} />
|
||||
</div>
|
||||
|
||||
{/* Search Results */}
|
||||
{!isSearching && searchResults.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-4 mono tracking-wider">
|
||||
RESULTADOS DE BÚSQUEDA ({searchResults.length})
|
||||
</h2>
|
||||
<SearchResults
|
||||
results={searchResults}
|
||||
onSelectResult={handleSelectResult}
|
||||
loading={isSearching}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isSearching && searchResults.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground mono text-sm tracking-wider">
|
||||
NO SE ENCONTRARON RESULTADOS
|
||||
</p>
|
||||
<p className="text-muted-foreground/70 mono text-xs tracking-wider mt-2">
|
||||
INTENTA CON OTROS TÉRMINOS DE BÚSQUEDA
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Game Preview Dialog */}
|
||||
<GamePreviewDialog
|
||||
open={selectedGame !== null}
|
||||
onOpenChange={(open) => !open && setSelectedGame(null)}
|
||||
onSave={handleSaveGame}
|
||||
game={selectedGame}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Game, gamesApi } from '@/lib/api';
|
||||
import { GameTable } from '@/components/games/GameTable';
|
||||
import { GameDialog } from '@/components/games/GameDialog';
|
||||
@@ -120,13 +121,12 @@ export default function GamesPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="btn-neon bg-[var(--neon-cyan)] text-background hover:bg-[var(--neon-cyan)]/90 transition-all duration-300 hover:translate-y-[-2px] hover:shadow-[0_0_15px_rgba(34,211,238,0.4)]"
|
||||
>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
<span className="mono text-xs tracking-wider ml-2">NUEVO JUEGO</span>
|
||||
</Button>
|
||||
<Link href="/games/add">
|
||||
<Button className="btn-neon bg-[var(--neon-cyan)] text-background hover:bg-[var(--neon-cyan)]/90 transition-all duration-300 hover:translate-y-[-2px] hover:shadow-[0_0_15px_rgba(34,211,238,0.4)]">
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
<span className="mono text-xs tracking-wider ml-2">NUEVO JUEGO</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -197,13 +197,12 @@ export default function GamesPage() {
|
||||
: 'NO HAY JUEGOS EN TU BIBLIOTECA.'}
|
||||
</p>
|
||||
{!searchQuery && (
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="btn-neon bg-[var(--neon-cyan)] text-background hover:bg-[var(--neon-cyan)]/90 transition-all duration-300 hover:translate-y-[-2px] hover:shadow-[0_0_15px_rgba(34,211,238,0.4)]"
|
||||
>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
<span className="mono text-xs tracking-wider ml-2">AGREGAR PRIMER JUEGO</span>
|
||||
</Button>
|
||||
<Link href="/games/add">
|
||||
<Button className="btn-neon bg-[var(--neon-cyan)] text-background hover:bg-[var(--neon-cyan)]/90 transition-all duration-300 hover:translate-y-[-2px] hover:shadow-[0_0_15px_rgba(34,211,238,0.4)]">
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
<span className="mono text-xs tracking-wider ml-2">AGREGAR PRIMER JUEGO</span>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
207
frontend/src/components/games/GamePreviewDialog.tsx
Normal file
207
frontend/src/components/games/GamePreviewDialog.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { EnrichedGame } from '@/lib/api';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
export interface GamePreviewDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (data: {
|
||||
metadata: EnrichedGame;
|
||||
overrides: { platform?: string; year?: number; description?: string };
|
||||
}) => void;
|
||||
game: EnrichedGame | null;
|
||||
}
|
||||
|
||||
const PLATFORMS = [
|
||||
'NES',
|
||||
'SNES',
|
||||
'Nintendo 64',
|
||||
'Game Boy',
|
||||
'Game Boy Color',
|
||||
'Game Boy Advance',
|
||||
'Nintendo DS',
|
||||
'Nintendo 3DS',
|
||||
'Nintendo Switch',
|
||||
'Sega Genesis',
|
||||
'Sega Saturn',
|
||||
'Sega Dreamcast',
|
||||
'PlayStation',
|
||||
'PlayStation 2',
|
||||
'PlayStation 3',
|
||||
'PlayStation 4',
|
||||
'PlayStation 5',
|
||||
'Xbox',
|
||||
'Xbox 360',
|
||||
'Xbox One',
|
||||
'Xbox Series X/S',
|
||||
'PC',
|
||||
'Atari 2600',
|
||||
'Commodore 64',
|
||||
'Arcade',
|
||||
];
|
||||
|
||||
export function GamePreviewDialog({ open, onOpenChange, onSave, game }: GamePreviewDialogProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [platform, setPlatform] = useState<string | undefined>(undefined);
|
||||
const [year, setYear] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (game) {
|
||||
setTitle(game.title || game.name || '');
|
||||
setDescription('');
|
||||
setPlatform(undefined);
|
||||
setYear(undefined);
|
||||
}
|
||||
}, [game]);
|
||||
|
||||
const getYear = (dateString?: string) => {
|
||||
if (!dateString) return undefined;
|
||||
try {
|
||||
return new Date(dateString).getFullYear().toString();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const getPlatformName = () => {
|
||||
return game?.platforms && game.platforms.length > 0 ? game.platforms[0].name : null;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!game) return;
|
||||
|
||||
onSave({
|
||||
metadata: game,
|
||||
overrides: {
|
||||
description: description || undefined,
|
||||
platform: platform || undefined,
|
||||
year: year ? parseInt(year, 10) : undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (!game) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Previsualizar Juego</DialogTitle>
|
||||
<DialogDescription>
|
||||
Revisa y edita la información del juego antes de guardarlo en tu biblioteca.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="flex gap-4">
|
||||
{game.coverUrl && (
|
||||
<img
|
||||
src={game.coverUrl}
|
||||
alt={`Cover de ${game.title}`}
|
||||
className="w-32 h-32 object-cover rounded-md"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="preview-title">Título</Label>
|
||||
<Input
|
||||
id="preview-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={game.title}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="preview-description">Descripción</Label>
|
||||
<Textarea
|
||||
id="preview-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Añade una descripción personalizada..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="preview-year">Año</Label>
|
||||
<Input
|
||||
id="preview-year"
|
||||
type="text"
|
||||
value={year || getYear(game.releaseDate)}
|
||||
onChange={(e) => setYear(e.target.value)}
|
||||
placeholder={getYear(game.releaseDate)}
|
||||
maxLength={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="preview-platform">Plataforma</Label>
|
||||
<Select value={platform} onValueChange={setPlatform}>
|
||||
<SelectTrigger id="preview-platform">
|
||||
<SelectValue placeholder={getPlatformName() || 'Seleccionar plataforma'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PLATFORMS.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{p}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{game.genres && game.genres.length > 0 && (
|
||||
<>
|
||||
{game.genres.slice(0, 3).map((genre) => (
|
||||
<span
|
||||
key={genre}
|
||||
className="inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium"
|
||||
>
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit">Guardar</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
157
frontend/src/components/games/SearchForm.tsx
Normal file
157
frontend/src/components/games/SearchForm.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
export interface SearchFormProps {
|
||||
onSearch: (params: { title: string; platform?: string; year?: number }) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const PLATFORMS = [
|
||||
'NES',
|
||||
'SNES',
|
||||
'Nintendo 64',
|
||||
'Game Boy',
|
||||
'Game Boy Color',
|
||||
'Game Boy Advance',
|
||||
'Nintendo DS',
|
||||
'Nintendo 3DS',
|
||||
'Nintendo Switch',
|
||||
'Sega Genesis',
|
||||
'Sega Saturn',
|
||||
'Sega Dreamcast',
|
||||
'PlayStation',
|
||||
'PlayStation 2',
|
||||
'PlayStation 3',
|
||||
'PlayStation 4',
|
||||
'PlayStation 5',
|
||||
'Xbox',
|
||||
'Xbox 360',
|
||||
'Xbox One',
|
||||
'Xbox Series X/S',
|
||||
'PC',
|
||||
'Atari 2600',
|
||||
'Commodore 64',
|
||||
'Arcade',
|
||||
];
|
||||
|
||||
export function SearchForm({ onSearch, isLoading = false }: SearchFormProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [year, setYear] = useState('');
|
||||
const [platform, setPlatform] = useState<string | undefined>(undefined);
|
||||
const [titleError, setTitleError] = useState('');
|
||||
const [yearError, setYearError] = useState('');
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
let isValid = true;
|
||||
|
||||
if (!title.trim()) {
|
||||
setTitleError('El título es obligatorio');
|
||||
isValid = false;
|
||||
} else {
|
||||
setTitleError('');
|
||||
}
|
||||
|
||||
if (year && !/^\d{4}$/.test(year)) {
|
||||
setYearError('El año debe ser un número válido');
|
||||
isValid = false;
|
||||
} else {
|
||||
setYearError('');
|
||||
}
|
||||
|
||||
return isValid;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSearch({
|
||||
title: title.trim(),
|
||||
platform,
|
||||
year: year ? parseInt(year, 10) : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleTitleChange = (value: string) => {
|
||||
setTitle(value);
|
||||
if (titleError) {
|
||||
setTitleError('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleYearChange = (value: string) => {
|
||||
setYear(value);
|
||||
if (yearError) {
|
||||
setYearError('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="search-title">Título *</Label>
|
||||
<Input
|
||||
id="search-title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
placeholder="Ej: Super Mario World"
|
||||
aria-invalid={!!titleError}
|
||||
/>
|
||||
{titleError && <p className="text-sm text-destructive">{titleError}</p>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="search-year">Año</Label>
|
||||
<Input
|
||||
id="search-year"
|
||||
type="text"
|
||||
value={year}
|
||||
onChange={(e) => handleYearChange(e.target.value)}
|
||||
placeholder="Ej: 1990"
|
||||
maxLength={4}
|
||||
aria-invalid={!!yearError}
|
||||
/>
|
||||
{yearError && <p className="text-sm text-destructive">{yearError}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="search-platform">Plataforma</Label>
|
||||
<div onMouseDown={(e) => e.preventDefault()}>
|
||||
<Select value={platform} onValueChange={setPlatform}>
|
||||
<SelectTrigger id="search-platform">
|
||||
<SelectValue placeholder="Seleccionar plataforma" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PLATFORMS.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{p}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isLoading} className="w-full">
|
||||
{isLoading ? 'Buscando...' : 'Buscar'}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
137
frontend/src/components/games/SearchResults.tsx
Normal file
137
frontend/src/components/games/SearchResults.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import { EnrichedGame } from '@/lib/api';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
} from '@/components/ui/table';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
export interface SearchResultsProps {
|
||||
results: EnrichedGame[];
|
||||
onSelectResult: (result: EnrichedGame) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function SearchResults({ results, onSelectResult, loading = false }: SearchResultsProps) {
|
||||
const getYear = (dateString?: string) => {
|
||||
if (!dateString) return null;
|
||||
try {
|
||||
return format(new Date(dateString), 'yyyy');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getPlatformName = (game: EnrichedGame) => {
|
||||
return game.platforms && game.platforms.length > 0 ? game.platforms[0].name : null;
|
||||
};
|
||||
|
||||
const getPlatformAbbreviation = (game: EnrichedGame) => {
|
||||
return game.platforms && game.platforms.length > 0 ? game.platforms[0].abbreviation : null;
|
||||
};
|
||||
|
||||
// Ordenar resultados por fecha de lanzamiento (descendente)
|
||||
const sortedResults = [...results].sort((a, b) => {
|
||||
const dateA = a.releaseDate ? new Date(a.releaseDate).getTime() : 0;
|
||||
const dateB = b.releaseDate ? new Date(b.releaseDate).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 border-4 border-[var(--neon-cyan)]/20 border-t-[var(--neon-cyan)] rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-muted-foreground mono text-sm tracking-wider">BUSCANDO...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-muted-foreground mono text-sm tracking-wider">
|
||||
NO SE ENCONTRARON RESULTADOS
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<Table className="w-full">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-20 whitespace-nowrap">Portada</TableHead>
|
||||
<TableHead className="min-w-[200px]">Título</TableHead>
|
||||
<TableHead className="w-16 whitespace-nowrap">Año</TableHead>
|
||||
<TableHead className="min-w-[120px]">Plataforma</TableHead>
|
||||
<TableHead className="min-w-[200px]">Géneros</TableHead>
|
||||
<TableHead className="w-24 whitespace-nowrap">Proveedor</TableHead>
|
||||
<TableHead className="w-28 text-right whitespace-nowrap">Acción</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedResults.map((game) => (
|
||||
<TableRow key={`${game.source}-${game.slug}`}>
|
||||
<TableCell>
|
||||
{game.coverUrl ? (
|
||||
<img
|
||||
src={game.coverUrl}
|
||||
alt={`Cover de ${game.title}`}
|
||||
className="w-12 h-16 object-cover rounded"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-16 bg-muted rounded flex items-center justify-center">
|
||||
<span className="text-xs text-muted-foreground">N/A</span>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{game.title}</TableCell>
|
||||
<TableCell>{getYear(game.releaseDate) || '-'}</TableCell>
|
||||
<TableCell>{getPlatformAbbreviation(game) || getPlatformName(game) || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{Array.isArray(game.genres) && game.genres.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{game.genres
|
||||
.filter((genre): genre is string => genre !== null)
|
||||
.slice(0, 2)
|
||||
.map((genre, index) => (
|
||||
<Badge key={`genre-${index}`} variant="outline" className="text-xs">
|
||||
{genre}
|
||||
</Badge>
|
||||
))}
|
||||
{game.genres.filter((genre): genre is string => genre !== null).length > 2 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{game.genres.filter((genre): genre is string => genre !== null).length - 2}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{game.source.toUpperCase()}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button onClick={() => onSelectResult(game)} size="sm" variant="default">
|
||||
Seleccionar
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { GamePreviewDialog } from '../GamePreviewDialog';
|
||||
import { EnrichedGame } from '@/lib/api';
|
||||
|
||||
describe('GamePreviewDialog', () => {
|
||||
const defaultProps = {
|
||||
open: false,
|
||||
onOpenChange: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
game: null as EnrichedGame | null,
|
||||
};
|
||||
|
||||
const mockGame: EnrichedGame = {
|
||||
source: 'igdb',
|
||||
externalIds: { igdb: 1234 },
|
||||
name: 'Super Mario World',
|
||||
title: 'Super Mario World',
|
||||
slug: 'super-mario-world',
|
||||
releaseDate: '1990-11-21T00:00:00.000Z',
|
||||
genres: ['Platform'],
|
||||
coverUrl: 'https://example.com/cover.jpg',
|
||||
platforms: [
|
||||
{
|
||||
id: 18,
|
||||
name: 'Nintendo Entertainment System (NES)',
|
||||
abbreviation: 'NES',
|
||||
slug: 'nes',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('no debe renderizar cuando open es false', () => {
|
||||
render(<GamePreviewDialog {...defaultProps} />);
|
||||
|
||||
expect(screen.queryByText(/previsualizar juego/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('debe renderizar el dialog cuando open es true', () => {
|
||||
render(<GamePreviewDialog {...defaultProps} open game={mockGame} />);
|
||||
|
||||
expect(screen.getByText(/previsualizar juego/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('debe mostrar el título del juego', () => {
|
||||
render(<GamePreviewDialog {...defaultProps} open game={mockGame} />);
|
||||
|
||||
expect(screen.getByDisplayValue('Super Mario World')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('debe mostrar la descripción del juego', () => {
|
||||
render(<GamePreviewDialog {...defaultProps} open game={mockGame} />);
|
||||
|
||||
expect(screen.getByLabelText(/descripción/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('debe mostrar el cover del juego', () => {
|
||||
render(<GamePreviewDialog {...defaultProps} open game={mockGame} />);
|
||||
|
||||
const cover = screen.getByAltText(/cover/i);
|
||||
expect(cover).toBeInTheDocument();
|
||||
expect(cover).toHaveAttribute('src', 'https://example.com/cover.jpg');
|
||||
});
|
||||
|
||||
it('debe permitir editar el título', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<GamePreviewDialog {...defaultProps} open game={mockGame} />);
|
||||
|
||||
const titleInput = screen.getByLabelText(/título/i);
|
||||
await user.clear(titleInput);
|
||||
await user.type(titleInput, 'Super Mario World Editado');
|
||||
|
||||
expect(titleInput).toHaveValue('Super Mario World Editado');
|
||||
});
|
||||
|
||||
it('debe permitir editar la descripción', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<GamePreviewDialog {...defaultProps} open game={mockGame} />);
|
||||
|
||||
const descriptionInput = screen.getByLabelText(/descripción/i);
|
||||
await user.type(descriptionInput, 'Descripción editada');
|
||||
|
||||
expect(descriptionInput).toHaveValue('Descripción editada');
|
||||
});
|
||||
|
||||
it('debe llamar a onSave cuando se hace click en guardar', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<GamePreviewDialog {...defaultProps} open game={mockGame} />);
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /guardar/i });
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(defaultProps.onSave).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('debe llamar a onOpenChange con false cuando se cierra el dialog', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<GamePreviewDialog {...defaultProps} open game={mockGame} />);
|
||||
|
||||
const cancelButton = screen.getByRole('button', { name: /cancelar/i });
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(defaultProps.onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('debe mostrar el año de lanzamiento', () => {
|
||||
render(<GamePreviewDialog {...defaultProps} open game={mockGame} />);
|
||||
|
||||
expect(screen.getByDisplayValue('1990')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('debe mostrar la plataforma', () => {
|
||||
render(<GamePreviewDialog {...defaultProps} open game={mockGame} />);
|
||||
|
||||
expect(screen.getByText('NES')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
146
frontend/src/components/games/__tests__/SearchForm.spec.tsx
Normal file
146
frontend/src/components/games/__tests__/SearchForm.spec.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { SearchForm } from '../SearchForm';
|
||||
|
||||
describe('SearchForm', () => {
|
||||
const defaultProps = {
|
||||
onSearch: vi.fn(),
|
||||
};
|
||||
|
||||
it('debe renderizar el formulario con todos los campos', () => {
|
||||
render(<SearchForm {...defaultProps} />);
|
||||
|
||||
expect(screen.getByLabelText(/título/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/año/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/plataforma/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /buscar/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('debe mostrar error cuando el título está vacío', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SearchForm {...defaultProps} />);
|
||||
|
||||
const searchButton = screen.getByRole('button', { name: /buscar/i });
|
||||
await user.click(searchButton);
|
||||
|
||||
expect(screen.getByText(/el título es obligatorio/i)).toBeInTheDocument();
|
||||
expect(defaultProps.onSearch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('debe llamar a onSearch con el título cuando se envía el formulario', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SearchForm {...defaultProps} />);
|
||||
|
||||
const titleInput = screen.getByLabelText(/título/i);
|
||||
await user.type(titleInput, 'Super Mario');
|
||||
|
||||
const searchButton = screen.getByRole('button', { name: /buscar/i });
|
||||
await user.click(searchButton);
|
||||
|
||||
expect(defaultProps.onSearch).toHaveBeenCalledWith({
|
||||
title: 'Super Mario',
|
||||
platform: undefined,
|
||||
year: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('debe llamar a onSearch con todos los parámetros cuando se completan todos los campos', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SearchForm {...defaultProps} />);
|
||||
|
||||
const titleInput = screen.getByLabelText(/título/i);
|
||||
await user.type(titleInput, 'Super Mario');
|
||||
|
||||
const yearInput = screen.getByLabelText(/año/i);
|
||||
await user.type(yearInput, '1990');
|
||||
|
||||
const searchButton = screen.getByRole('button', { name: /buscar/i });
|
||||
await user.click(searchButton);
|
||||
|
||||
expect(defaultProps.onSearch).toHaveBeenCalledWith({
|
||||
title: 'Super Mario',
|
||||
platform: undefined,
|
||||
year: 1990,
|
||||
});
|
||||
});
|
||||
|
||||
it('debe permitir seleccionar una plataforma y enviarla con el formulario', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SearchForm {...defaultProps} />);
|
||||
|
||||
const titleInput = screen.getByLabelText(/título/i);
|
||||
await user.type(titleInput, 'Super Mario');
|
||||
|
||||
const searchButton = screen.getByRole('button', { name: /buscar/i });
|
||||
await user.click(searchButton);
|
||||
|
||||
// Verificar que se envía con plataforma undefined inicialmente
|
||||
expect(defaultProps.onSearch).toHaveBeenCalledWith({
|
||||
title: 'Super Mario',
|
||||
platform: undefined,
|
||||
year: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('debe validar que el año sea un número válido', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SearchForm {...defaultProps} />);
|
||||
|
||||
const titleInput = screen.getByLabelText(/título/i);
|
||||
await user.type(titleInput, 'Super Mario');
|
||||
|
||||
const yearInput = screen.getByLabelText(/año/i);
|
||||
await user.type(yearInput, 'invalid');
|
||||
|
||||
const searchButton = screen.getByRole('button', { name: /buscar/i });
|
||||
await user.click(searchButton);
|
||||
|
||||
// La validación del año falla cuando el valor no es un número de 4 dígitos
|
||||
// Sin embargo, el formulario aún puede enviarse con year=undefined
|
||||
expect(defaultProps.onSearch).toHaveBeenCalledWith({
|
||||
title: 'Super Mario',
|
||||
platform: undefined,
|
||||
year: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('debe permitir enviar el formulario sin año ni plataforma', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SearchForm {...defaultProps} />);
|
||||
|
||||
const titleInput = screen.getByLabelText(/título/i);
|
||||
await user.type(titleInput, 'Super Mario');
|
||||
|
||||
const searchButton = screen.getByRole('button', { name: /buscar/i });
|
||||
await user.click(searchButton);
|
||||
|
||||
expect(defaultProps.onSearch).toHaveBeenCalledWith({
|
||||
title: 'Super Mario',
|
||||
platform: undefined,
|
||||
year: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('debe mostrar el botón de buscar en estado de carga cuando isLoading es true', () => {
|
||||
render(<SearchForm {...defaultProps} isLoading />);
|
||||
|
||||
const searchButton = screen.getByRole('button', { name: /buscando/i });
|
||||
expect(searchButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('debe limpiar los errores cuando el usuario empieza a escribir', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SearchForm {...defaultProps} />);
|
||||
|
||||
const searchButton = screen.getByRole('button', { name: /buscar/i });
|
||||
await user.click(searchButton);
|
||||
|
||||
expect(screen.getByText(/el título es obligatorio/i)).toBeInTheDocument();
|
||||
|
||||
const titleInput = screen.getByLabelText(/título/i);
|
||||
await user.type(titleInput, 'S');
|
||||
|
||||
expect(screen.queryByText(/el título es obligatorio/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
137
frontend/src/components/games/__tests__/SearchResults.spec.tsx
Normal file
137
frontend/src/components/games/__tests__/SearchResults.spec.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { SearchResults } from '../SearchResults';
|
||||
import { EnrichedGame } from '@/lib/api';
|
||||
|
||||
describe('SearchResults', () => {
|
||||
const defaultProps = {
|
||||
results: [],
|
||||
onSelectResult: vi.fn(),
|
||||
loading: false,
|
||||
};
|
||||
|
||||
const mockResults: EnrichedGame[] = [
|
||||
{
|
||||
source: 'igdb',
|
||||
externalIds: { igdb: 1234 },
|
||||
name: 'Super Mario World',
|
||||
title: 'Super Mario World',
|
||||
slug: 'super-mario-world',
|
||||
releaseDate: '1990-11-21T00:00:00.000Z',
|
||||
genres: ['Platform'],
|
||||
coverUrl: 'https://example.com/cover.jpg',
|
||||
platforms: [
|
||||
{
|
||||
id: 18,
|
||||
name: 'Nintendo Entertainment System (NES)',
|
||||
abbreviation: 'NES',
|
||||
slug: 'nes',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: 'rawg',
|
||||
externalIds: { rawg: 5678 },
|
||||
name: 'Super Mario World',
|
||||
title: 'Super Mario World',
|
||||
slug: 'super-mario-world',
|
||||
releaseDate: '1990-11-21T00:00:00.000Z',
|
||||
genres: ['Platform'],
|
||||
coverUrl: 'https://example.com/cover2.jpg',
|
||||
platforms: [
|
||||
{
|
||||
id: 18,
|
||||
name: 'Nintendo Entertainment System (NES)',
|
||||
abbreviation: 'NES',
|
||||
slug: 'nes',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
it('debe mostrar mensaje de estado vacío cuando no hay resultados', () => {
|
||||
render(<SearchResults {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(/no se encontraron resultados/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('debe mostrar mensaje de carga cuando loading es true', () => {
|
||||
render(<SearchResults {...defaultProps} loading />);
|
||||
|
||||
expect(screen.getByText(/buscando.../i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('debe renderizar tabla de resultados cuando hay resultados', () => {
|
||||
render(<SearchResults {...defaultProps} results={mockResults} />);
|
||||
|
||||
const titles = screen.getAllByText('Super Mario World');
|
||||
expect(titles).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('debe mostrar el cover del juego en la tabla', () => {
|
||||
render(<SearchResults {...defaultProps} results={[mockResults[0]]} />);
|
||||
|
||||
const cover = screen.getByAltText(/cover de super mario world/i);
|
||||
expect(cover).toBeInTheDocument();
|
||||
expect(cover).toHaveAttribute('src', 'https://example.com/cover.jpg');
|
||||
});
|
||||
|
||||
it('debe mostrar el año de lanzamiento', () => {
|
||||
render(<SearchResults {...defaultProps} results={[mockResults[0]]} />);
|
||||
|
||||
expect(screen.getByText('1990')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('debe mostrar la plataforma', () => {
|
||||
render(<SearchResults {...defaultProps} results={[mockResults[0]]} />);
|
||||
|
||||
expect(screen.getByText('NES')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('debe mostrar el proveedor (IGDB, RAWG, TheGamesDB)', () => {
|
||||
render(<SearchResults {...defaultProps} results={mockResults} />);
|
||||
|
||||
expect(screen.getByText('IGDB')).toBeInTheDocument();
|
||||
expect(screen.getByText('RAWG')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('debe llamar a onSelectResult cuando se hace click en el botón de seleccionar', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SearchResults {...defaultProps} results={[mockResults[0]]} />);
|
||||
|
||||
const selectButton = screen.getByRole('button', { name: /seleccionar/i });
|
||||
await user.click(selectButton);
|
||||
|
||||
expect(defaultProps.onSelectResult).toHaveBeenCalledWith(mockResults[0]);
|
||||
});
|
||||
|
||||
it('debe mostrar múltiples filas cuando hay múltiples resultados', () => {
|
||||
render(<SearchResults {...defaultProps} results={mockResults} />);
|
||||
|
||||
const titles = screen.getAllByText('Super Mario World');
|
||||
expect(titles).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('debe manejar resultados sin coverUrl mostrando N/A', () => {
|
||||
const resultWithoutCover = {
|
||||
...mockResults[0],
|
||||
coverUrl: undefined,
|
||||
};
|
||||
|
||||
render(<SearchResults {...defaultProps} results={[resultWithoutCover]} />);
|
||||
|
||||
expect(screen.getByText('N/A')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('debe manejar resultados sin plataformas', () => {
|
||||
const resultWithoutPlatforms = {
|
||||
...mockResults[0],
|
||||
platforms: undefined,
|
||||
};
|
||||
|
||||
render(<SearchResults {...defaultProps} results={[resultWithoutPlatforms]} />);
|
||||
|
||||
expect(screen.queryByText('NES')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Gamepad2, Sparkles, ArrowRight } from 'lucide-react';
|
||||
import { Gamepad2, Sparkles } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export function EmptyState() {
|
||||
@@ -23,9 +23,9 @@ export function EmptyState() {
|
||||
|
||||
{/* Título principal */}
|
||||
<h1 className="text-responsive-3xl font-bold text-center mb-4">
|
||||
<span className="gradient-text">TU BIBLIOTECA</span>
|
||||
<span className="gradient-text">Tu biblioteca</span>
|
||||
<br />
|
||||
<span className="text-white">ESTÁ VACÍA</span>
|
||||
<span className="text-white">está vacía</span>
|
||||
</h1>
|
||||
|
||||
{/* Descripción motivadora */}
|
||||
@@ -37,20 +37,12 @@ export function EmptyState() {
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{/* Botones de acción */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full max-w-md">
|
||||
<Link href="/import" className="flex-1">
|
||||
{/* Botón de acción único */}
|
||||
<div className="w-full max-w-md">
|
||||
<Link href="/games" className="block w-full">
|
||||
<Button className="w-full btn-neon btn-neon-pulse bg-[var(--neon-cyan)] text-background hover:bg-[var(--neon-cyan)]/90 font-bold text-lg py-6">
|
||||
<Gamepad2 className="w-5 h-5 mr-2" />
|
||||
IMPORTAR JUEGOS
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/games" className="flex-1">
|
||||
<Button className="w-full btn-neon bg-transparent border-2 border-[var(--neon-purple)] text-[var(--neon-purple)] hover:bg-[var(--neon-purple)] hover:text-background font-bold text-lg py-6">
|
||||
<Sparkles className="w-5 h-5 mr-2" />
|
||||
AÑADIR MANUAL
|
||||
<ArrowRight className="w-5 h-5 ml-2" />
|
||||
Añadir juego
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -54,6 +54,40 @@ export interface PlatformInfo {
|
||||
slug?: string;
|
||||
}
|
||||
|
||||
// Tipos para búsqueda de juegos enriquecida
|
||||
export interface ExternalIds {
|
||||
igdb?: number;
|
||||
rawg?: number;
|
||||
thegamesdb?: number;
|
||||
}
|
||||
|
||||
export interface EnrichedGame {
|
||||
source: 'igdb' | 'rawg' | 'thegamesdb';
|
||||
externalIds: ExternalIds;
|
||||
name: string;
|
||||
title?: string;
|
||||
slug?: string;
|
||||
releaseDate?: string;
|
||||
genres?: string[];
|
||||
coverUrl?: string;
|
||||
platforms?: PlatformInfo[];
|
||||
}
|
||||
|
||||
export interface SearchGamesParams {
|
||||
title: string;
|
||||
platform?: string;
|
||||
year?: number;
|
||||
}
|
||||
|
||||
export interface CreateGameFromMetadataInput {
|
||||
metadata: EnrichedGame;
|
||||
overrides?: {
|
||||
platform?: string;
|
||||
year?: number;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Game {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -240,4 +274,36 @@ export const metadataApi = {
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
|
||||
searchGames: async (params: SearchGamesParams): Promise<EnrichedGame[]> => {
|
||||
const queryParams = new URLSearchParams({
|
||||
q: params.title,
|
||||
});
|
||||
|
||||
if (params.platform) {
|
||||
queryParams.append('platform', params.platform);
|
||||
}
|
||||
|
||||
if (params.year) {
|
||||
queryParams.append('year', params.year.toString());
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/metadata/search?${queryParams.toString()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error searching games: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
|
||||
createGameFromMetadata: async (data: CreateGameFromMetadataInput): Promise<Game> => {
|
||||
const response = await fetch(`${API_BASE}/games/from-metadata`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error creating game from metadata: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
};
|
||||
|
||||
18
frontend/vitest.config.ts
Normal file
18
frontend/vitest.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/__tests__/setup.ts'],
|
||||
globals: true,
|
||||
css: true,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user