diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 23e00e2..aca5b1e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { CombatPage } from './pages/CombatPage'; import { InventoryPage } from './pages/InventoryPage'; import { CraftPage } from './pages/CraftPage'; import { ForgePage } from './pages/ForgePage'; +import { QuestPage } from './pages/QuestPage'; const qc = new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 30_000 } } }); @@ -29,6 +30,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 3b8aefd..a71fffc 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -50,6 +50,16 @@ export const craftApi = { collect: (jobId: string) => api.post(`/craft/collect/${jobId}`), }; +// Quests +export const questApi = { + available: () => api.get('/quests/available'), + active: () => api.get('/quests/active'), + completed: () => api.get('/quests/completed'), + accept: (questId: string) => api.post(`/quests/accept/${questId}`), + claim: (playerQuestId: string) => api.post(`/quests/claim/${playerQuestId}`), + arcs: () => api.get('/quests/arcs'), +}; + // Forge export const forgeApi = { upgrade: (characterItemId: string) => diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 9dbf059..42ee373 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,9 +1,10 @@ import { Link, useLocation } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; -import { Swords, Package, Hammer, User, LogOut, Shield } from 'lucide-react'; +import { Swords, Package, Hammer, User, LogOut, Shield, Scroll } from 'lucide-react'; const NAV = [ { to: '/dashboard', icon: User, label: 'Personnage' }, + { to: '/quests', icon: Scroll, label: 'Quêtes' }, { to: '/combat', icon: Swords, label: 'Combat' }, { to: '/inventory', icon: Package, label: 'Inventaire' }, { to: '/craft', icon: Hammer, label: 'Artisanat' }, diff --git a/frontend/src/pages/QuestPage.tsx b/frontend/src/pages/QuestPage.tsx new file mode 100644 index 0000000..e044234 --- /dev/null +++ b/frontend/src/pages/QuestPage.tsx @@ -0,0 +1,208 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { questApi } from '../api/endpoints'; +import { Scroll, CheckCircle, Circle, Trophy, ChevronDown, ChevronRight, Star, Coins, Swords } from 'lucide-react'; +import { useState } from 'react'; + +const OBJ_LABELS: Record = { + kill_monster: 'Tuer', + kill_any: 'Gagner des combats', + gather_material: 'Récolter', + craft_item: 'Crafter', + forge_item: 'Forger', +}; + +function QuestCard({ pq, mode }: { pq: any; mode: 'active' | 'available' | 'completed' }) { + const qc = useQueryClient(); + const quest = mode === 'active' ? pq.quest : pq; + const progress = mode === 'active' ? pq.progress : 0; + const status = mode === 'active' ? pq.status : 'available'; + const pct = Math.min(100, Math.floor((progress / quest.objectiveCount) * 100)); + + const acceptMut = useMutation({ + mutationFn: () => questApi.accept(quest.id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['quests'] }); + qc.invalidateQueries({ queryKey: ['questsActive'] }); + qc.invalidateQueries({ queryKey: ['questsAvailable'] }); + }, + }); + + const claimMut = useMutation({ + mutationFn: () => questApi.claim(pq.id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['quests'] }); + qc.invalidateQueries({ queryKey: ['questsActive'] }); + qc.invalidateQueries({ queryKey: ['questsAvailable'] }); + qc.invalidateQueries({ queryKey: ['questsCompleted'] }); + qc.invalidateQueries({ queryKey: ['questArcs'] }); + qc.invalidateQueries({ queryKey: ['character'] }); + }, + }); + + const isCompleted = status === 'completed'; + const isClaimed = status === 'claimed'; + + return ( +
+
+
+
+ {isClaimed ? : isCompleted ? : } + {quest.name} + {quest.repeatable && répétable} +
+

{quest.description}

+
+
+ + {/* Objectif */} +
+ {OBJ_LABELS[quest.objectiveType] ?? quest.objectiveType} — {mode === 'active' ? `${progress}/${quest.objectiveCount}` : `×${quest.objectiveCount}`} +
+ + {/* Progress bar (active quests only) */} + {mode === 'active' && ( +
+
+
+ )} + + {/* Rewards */} +
+ {quest.rewardXp} XP + {quest.rewardGold} or + {quest.rewardTitle && 🏅 {quest.rewardTitle}} + {quest.minLevel > 1 && Niv. {quest.minLevel}+} +
+ + {/* Actions */} + {mode === 'available' && ( + + )} + {mode === 'active' && isCompleted && ( + + )} + {acceptMut.isError &&

{(acceptMut.error as Error).message}

} + {claimMut.isError &&

{(claimMut.error as Error).message}

} +
+ ); +} + +function ArcSection({ arc }: { arc: any }) { + const [open, setOpen] = useState(true); + const { completed, total } = arc.progress; + + return ( +
+
setOpen(!open)} + > + {open ? : } + + {arc.name} + {completed}/{total} + {arc.completed && } +
+ {open && ( + <> +

{arc.description}

+
+ {arc.quests.map((q: any) => ( +
+ {q.playerStatus === 'claimed' + ? + : q.playerStatus === 'completed' + ? + : q.playerStatus === 'active' + ? + : + } + {q.name} + {q.rewardXp} XP + {q.minLevel > 1 && Niv.{q.minLevel}+} +
+ ))} +
+ + )} +
+ ); +} + +export function QuestPage() { + const { data: active, isLoading: loadActive } = useQuery({ queryKey: ['questsActive'], queryFn: questApi.active }); + const { data: available, isLoading: loadAvail } = useQuery({ queryKey: ['questsAvailable'], queryFn: questApi.available }); + const { data: arcs } = useQuery({ queryKey: ['questArcs'], queryFn: questApi.arcs }); + + if (loadActive || loadAvail) return
Chargement…
; + + const activeCount = active?.length ?? 0; + + return ( +
+

📜 Quêtes

+ +
+ {/* Active quests */} +
+

+ Quêtes actives ({activeCount}/3) +

+ {active && active.length > 0 ? ( +
+ {active.map((pq: any) => )} +
+ ) : ( +
+ Aucune quête active — acceptez-en dans le panneau de droite +
+ )} +
+ + {/* Available quests */} +
+

+ Quêtes disponibles +

+ {available && available.length > 0 ? ( +
+ {available.map((q: any) => )} +
+ ) : ( +
+ Toutes les quêtes sont acceptées ou complétées +
+ )} +
+
+ + {/* Arcs narratifs */} + {arcs && arcs.length > 0 && ( +
+

+ 📖 Arcs narratifs +

+ {arcs.map((arc: any) => )} +
+ )} +
+ ); +}