feat: quest page frontend — accept, progress, claim, arcs narratifs
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s

Nouvelle page /quests avec icône Scroll dans la sidebar.
Layout: quêtes actives (gauche) + disponibles (droite) + arcs en bas.
Progress bars, boutons accepter/réclamer, badges répétable.
Arc section collapsible avec status par quête.
This commit is contained in:
2026-03-24 16:40:04 +01:00
parent 7651f3d8aa
commit 8038ca5d0a
4 changed files with 222 additions and 1 deletions

View File

@@ -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() {
<Route path="/login" element={<LoginPage />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/dashboard" element={<ProtectedLayout><DashboardPage /></ProtectedLayout>} />
<Route path="/quests" element={<ProtectedLayout><QuestPage /></ProtectedLayout>} />
<Route path="/combat" element={<ProtectedLayout><CombatPage /></ProtectedLayout>} />
<Route path="/inventory" element={<ProtectedLayout><InventoryPage /></ProtectedLayout>} />
<Route path="/craft" element={<ProtectedLayout><CraftPage /></ProtectedLayout>} />

View File

@@ -50,6 +50,16 @@ export const craftApi = {
collect: (jobId: string) => api.post<CharacterItem>(`/craft/collect/${jobId}`),
};
// Quests
export const questApi = {
available: () => api.get<any[]>('/quests/available'),
active: () => api.get<any[]>('/quests/active'),
completed: () => api.get<any[]>('/quests/completed'),
accept: (questId: string) => api.post<any>(`/quests/accept/${questId}`),
claim: (playerQuestId: string) => api.post<any>(`/quests/claim/${playerQuestId}`),
arcs: () => api.get<any[]>('/quests/arcs'),
};
// Forge
export const forgeApi = {
upgrade: (characterItemId: string) =>

View File

@@ -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' },

View File

@@ -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<string, string> = {
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 (
<div className={`card ${isCompleted ? 'card-gold' : ''}`} style={{ padding: '0.75rem 1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 4 }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{isClaimed ? <CheckCircle size={14} color="#3ddc84" /> : isCompleted ? <Trophy size={14} color="#f4c94e" /> : <Circle size={13} color="#6b7a99" />}
<span style={{ fontWeight: 700, fontSize: 13, color: isCompleted ? '#f4c94e' : '#dce4f0' }}>{quest.name}</span>
{quest.repeatable && <span style={{ fontSize: 9, color: '#5ba4f5', background: '#1a2540', padding: '1px 5px', borderRadius: 4 }}>répétable</span>}
</div>
<p style={{ margin: '4px 0 0', fontSize: 11, color: '#6b7a99' }}>{quest.description}</p>
</div>
</div>
{/* Objectif */}
<div style={{ fontSize: 11, color: '#9ca3af', margin: '6px 0 4px' }}>
{OBJ_LABELS[quest.objectiveType] ?? quest.objectiveType} {mode === 'active' ? `${progress}/${quest.objectiveCount}` : `×${quest.objectiveCount}`}
</div>
{/* Progress bar (active quests only) */}
{mode === 'active' && (
<div style={{ background: '#1e2535', borderRadius: 4, height: 6, marginBottom: 6, overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: isCompleted ? '#f4c94e' : '#5ba4f5', borderRadius: 4, transition: 'width 0.3s' }} />
</div>
)}
{/* Rewards */}
<div style={{ display: 'flex', gap: 12, fontSize: 11, color: '#6b7a99', marginBottom: 6 }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 3 }}><Star size={10} color="#a78bfa" /> {quest.rewardXp} XP</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 3 }}><Coins size={10} color="#f4c94e" /> {quest.rewardGold} or</span>
{quest.rewardTitle && <span style={{ color: '#f4c94e' }}>🏅 {quest.rewardTitle}</span>}
{quest.minLevel > 1 && <span>Niv. {quest.minLevel}+</span>}
</div>
{/* Actions */}
{mode === 'available' && (
<button
className="btn btn-ghost"
style={{ fontSize: 11, padding: '0.25rem 0.75rem' }}
disabled={acceptMut.isPending}
onClick={() => acceptMut.mutate()}
>
{acceptMut.isPending ? 'Acceptation…' : '+ Accepter'}
</button>
)}
{mode === 'active' && isCompleted && (
<button
className="btn btn-gold"
style={{ fontSize: 11, padding: '0.25rem 0.75rem' }}
disabled={claimMut.isPending}
onClick={() => claimMut.mutate()}
>
{claimMut.isPending ? 'Réclamation…' : '🎁 Réclamer la récompense'}
</button>
)}
{acceptMut.isError && <p style={{ color: '#e84040', fontSize: 11, marginTop: 4 }}>{(acceptMut.error as Error).message}</p>}
{claimMut.isError && <p style={{ color: '#e84040', fontSize: 11, marginTop: 4 }}>{(claimMut.error as Error).message}</p>}
</div>
);
}
function ArcSection({ arc }: { arc: any }) {
const [open, setOpen] = useState(true);
const { completed, total } = arc.progress;
return (
<div className="card" style={{ padding: '0.75rem 1rem', marginBottom: '0.5rem' }}>
<div
style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginBottom: open ? 8 : 0 }}
onClick={() => setOpen(!open)}
>
{open ? <ChevronDown size={14} color="#6b7a99" /> : <ChevronRight size={14} color="#6b7a99" />}
<Scroll size={14} color={arc.completed ? '#3ddc84' : '#f4c94e'} />
<span style={{ fontWeight: 700, fontSize: 14, color: arc.completed ? '#3ddc84' : '#f4c94e', flex: 1 }}>{arc.name}</span>
<span style={{ fontSize: 11, color: '#6b7a99' }}>{completed}/{total}</span>
{arc.completed && <CheckCircle size={14} color="#3ddc84" />}
</div>
{open && (
<>
<p style={{ fontSize: 11, color: '#6b7a99', margin: '0 0 8px', paddingLeft: 28 }}>{arc.description}</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, paddingLeft: 12 }}>
{arc.quests.map((q: any) => (
<div key={q.id} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, padding: '3px 0' }}>
{q.playerStatus === 'claimed'
? <CheckCircle size={12} color="#3ddc84" />
: q.playerStatus === 'completed'
? <Trophy size={12} color="#f4c94e" />
: q.playerStatus === 'active'
? <Swords size={12} color="#5ba4f5" />
: <Circle size={11} color="#3a4560" />
}
<span style={{
color: q.playerStatus === 'claimed' ? '#3ddc84' : q.playerStatus === 'active' ? '#dce4f0' : '#6b7a99',
flex: 1,
}}>{q.name}</span>
<span style={{ fontSize: 10, color: '#6b7a99' }}>{q.rewardXp} XP</span>
{q.minLevel > 1 && <span style={{ fontSize: 10, color: '#9ca3af' }}>Niv.{q.minLevel}+</span>}
</div>
))}
</div>
</>
)}
</div>
);
}
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 <div style={{ padding: '2rem', color: '#6b7a99' }}>Chargement</div>;
const activeCount = active?.length ?? 0;
return (
<div>
<h2 style={{ margin: '0 0 1rem', color: '#f4c94e', fontSize: 20 }}>📜 Quêtes</h2>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
{/* Active quests */}
<div>
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase' }}>
Quêtes actives ({activeCount}/3)
</p>
{active && active.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{active.map((pq: any) => <QuestCard key={pq.id} pq={pq} mode="active" />)}
</div>
) : (
<div className="card" style={{ padding: '1.5rem', textAlign: 'center', color: '#6b7a99', fontSize: 13 }}>
Aucune quête active acceptez-en dans le panneau de droite
</div>
)}
</div>
{/* Available quests */}
<div>
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase' }}>
Quêtes disponibles
</p>
{available && available.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{available.map((q: any) => <QuestCard key={q.id} pq={q} mode="available" />)}
</div>
) : (
<div className="card" style={{ padding: '1.5rem', textAlign: 'center', color: '#6b7a99', fontSize: 13 }}>
Toutes les quêtes sont acceptées ou complétées
</div>
)}
</div>
</div>
{/* Arcs narratifs */}
{arcs && arcs.length > 0 && (
<div style={{ marginTop: '1.5rem' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase' }}>
📖 Arcs narratifs
</p>
{arcs.map((arc: any) => <ArcSection key={arc.id} arc={arc} />)}
</div>
)}
</div>
);
}