feat: quest page frontend — accept, progress, claim, arcs narratifs
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
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:
@@ -9,6 +9,7 @@ import { CombatPage } from './pages/CombatPage';
|
|||||||
import { InventoryPage } from './pages/InventoryPage';
|
import { InventoryPage } from './pages/InventoryPage';
|
||||||
import { CraftPage } from './pages/CraftPage';
|
import { CraftPage } from './pages/CraftPage';
|
||||||
import { ForgePage } from './pages/ForgePage';
|
import { ForgePage } from './pages/ForgePage';
|
||||||
|
import { QuestPage } from './pages/QuestPage';
|
||||||
|
|
||||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 30_000 } } });
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 30_000 } } });
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ function AppRoutes() {
|
|||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||||
<Route path="/dashboard" element={<ProtectedLayout><DashboardPage /></ProtectedLayout>} />
|
<Route path="/dashboard" element={<ProtectedLayout><DashboardPage /></ProtectedLayout>} />
|
||||||
|
<Route path="/quests" element={<ProtectedLayout><QuestPage /></ProtectedLayout>} />
|
||||||
<Route path="/combat" element={<ProtectedLayout><CombatPage /></ProtectedLayout>} />
|
<Route path="/combat" element={<ProtectedLayout><CombatPage /></ProtectedLayout>} />
|
||||||
<Route path="/inventory" element={<ProtectedLayout><InventoryPage /></ProtectedLayout>} />
|
<Route path="/inventory" element={<ProtectedLayout><InventoryPage /></ProtectedLayout>} />
|
||||||
<Route path="/craft" element={<ProtectedLayout><CraftPage /></ProtectedLayout>} />
|
<Route path="/craft" element={<ProtectedLayout><CraftPage /></ProtectedLayout>} />
|
||||||
|
|||||||
@@ -50,6 +50,16 @@ export const craftApi = {
|
|||||||
collect: (jobId: string) => api.post<CharacterItem>(`/craft/collect/${jobId}`),
|
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
|
// Forge
|
||||||
export const forgeApi = {
|
export const forgeApi = {
|
||||||
upgrade: (characterItemId: string) =>
|
upgrade: (characterItemId: string) =>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
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 = [
|
const NAV = [
|
||||||
{ to: '/dashboard', icon: User, label: 'Personnage' },
|
{ to: '/dashboard', icon: User, label: 'Personnage' },
|
||||||
|
{ to: '/quests', icon: Scroll, label: 'Quêtes' },
|
||||||
{ to: '/combat', icon: Swords, label: 'Combat' },
|
{ to: '/combat', icon: Swords, label: 'Combat' },
|
||||||
{ to: '/inventory', icon: Package, label: 'Inventaire' },
|
{ to: '/inventory', icon: Package, label: 'Inventaire' },
|
||||||
{ to: '/craft', icon: Hammer, label: 'Artisanat' },
|
{ to: '/craft', icon: Hammer, label: 'Artisanat' },
|
||||||
|
|||||||
208
frontend/src/pages/QuestPage.tsx
Normal file
208
frontend/src/pages/QuestPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user