Files
TetaRdPG/frontend/src/pages/QuestPage.tsx
Tetardtek 06e082b11c
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 48s
feat: UI evolution — HudBar Tailwind + arcs collapsés intelligents
- HudBar: migration inline styles → Tailwind, breakpoint 480px ultra-compact mobile
- QuestPage: arcs fermés par défaut sauf quête active/à réclamer, barre progression par arc
- QuestPage: migration inline styles → Tailwind (QuestCard, ArcSection, ArcQuestRow)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 17:39:01 +02:00

324 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { questApi } from '../api/endpoints';
import { Scroll, CheckCircle, Circle, Trophy, ChevronDown, ChevronRight, Star, Coins, Swords, Lock } from 'lucide-react';
import { useState, useMemo } 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 useInvalidateQuests() {
const qc = useQueryClient();
return () => {
qc.invalidateQueries({ queryKey: ['quests'] });
qc.invalidateQueries({ queryKey: ['questsActive'] });
qc.invalidateQueries({ queryKey: ['questsAvailable'] });
qc.invalidateQueries({ queryKey: ['questsCompleted'] });
qc.invalidateQueries({ queryKey: ['questArcs'] });
qc.invalidateQueries({ queryKey: ['character'] });
};
}
function QuestCard({ pq, mode }: { pq: any; mode: 'active' | 'available' | 'completed' }) {
const invalidateAll = useInvalidateQuests();
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: invalidateAll,
});
const claimMut = useMutation({
mutationFn: () => questApi.claim(pq.id),
onSuccess: invalidateAll,
});
const abandonMut = useMutation({
mutationFn: () => questApi.abandon(pq.id),
onSuccess: invalidateAll,
});
const isCompleted = status === 'completed';
const isClaimed = status === 'claimed';
return (
<div className={`card ${isCompleted ? 'card-gold' : ''} py-3 px-4`}>
<div className="flex justify-between items-start mb-1">
<div className="flex-1">
<div className="flex items-center gap-1.5">
{isClaimed ? <CheckCircle size={14} className="text-rpg-green" /> : isCompleted ? <Trophy size={14} className="text-rpg-gold" /> : <Circle size={13} className="text-rpg-muted" />}
<span className={`font-bold text-[13px] ${isCompleted ? 'text-rpg-gold' : 'text-rpg-text'}`}>{quest.name}</span>
{quest.repeatable && <span className="text-[9px] text-rpg-blue bg-[#1a2540] px-1.5 py-px rounded">répétable</span>}
</div>
<p className="mt-1 mb-0 text-[11px] text-rpg-muted">{quest.description}</p>
</div>
</div>
{/* Objectif */}
<div className="text-[11px] text-[#9ca3af] mt-1.5 mb-1">
{OBJ_LABELS[quest.objectiveType] ?? quest.objectiveType} {mode === 'active' ? `${progress}/${quest.objectiveCount}` : `×${quest.objectiveCount}`}
</div>
{/* Progress bar (active quests only) */}
{mode === 'active' && (
<div className="bar-track mb-1.5" style={{ height: 6 }}>
<div className={isCompleted ? 'bar-fill-xp' : 'bar-fill-end'} style={{ width: `${pct}%` }} />
</div>
)}
{/* Rewards */}
<div className="flex gap-3 text-[11px] text-rpg-muted mb-1.5">
<span className="flex items-center gap-1"><Star size={10} className="text-rpg-purple" /> {quest.rewardXp} XP</span>
<span className="flex items-center gap-1"><Coins size={10} className="text-rpg-gold" /> {quest.rewardGold} or</span>
{quest.rewardTitle && <span className="text-rpg-gold">🏅 {quest.rewardTitle}</span>}
{quest.minLevel > 1 && <span>Niv. {quest.minLevel}+</span>}
</div>
{/* Actions */}
{mode === 'available' && (
<button className="btn btn-ghost text-[11px] py-1 px-3" disabled={acceptMut.isPending} onClick={() => acceptMut.mutate()}>
{acceptMut.isPending ? 'Acceptation…' : '+ Accepter'}
</button>
)}
{mode === 'active' && isCompleted && (
<button className="btn btn-gold text-[11px] py-1 px-3" disabled={claimMut.isPending} onClick={() => claimMut.mutate()}>
{claimMut.isPending ? 'Réclamation…' : '🎁 Réclamer la récompense'}
</button>
)}
{mode === 'active' && !isCompleted && (
<button className="btn btn-ghost text-[10px] py-0.5 px-2 text-rpg-muted" disabled={abandonMut.isPending} onClick={() => abandonMut.mutate()}>
{abandonMut.isPending ? '…' : '✕ Abandonner'}
</button>
)}
{acceptMut.isError && <p className="text-rpg-red text-[11px] mt-1">{(acceptMut.error as Error).message}</p>}
{claimMut.isError && <p className="text-rpg-red text-[11px] mt-1">{(claimMut.error as Error).message}</p>}
{abandonMut.isError && <p className="text-rpg-red text-[11px] mt-1">{(abandonMut.error as Error).message}</p>}
</div>
);
}
function ArcQuestRow({ q }: { q: any }) {
const qc = useQueryClient();
const acceptMut = useMutation({
mutationFn: () => questApi.accept(q.id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['questArcs'] });
qc.invalidateQueries({ queryKey: ['questsActive'] });
},
});
const claimMut = useMutation({
mutationFn: () => questApi.claim(q.playerQuestId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['questArcs'] });
qc.invalidateQueries({ queryKey: ['questsActive'] });
qc.invalidateQueries({ queryKey: ['character'] });
},
});
return (
<div className="flex items-center gap-2 text-xs py-1 border-b border-[#1a2030]">
{q.playerStatus === 'claimed'
? <CheckCircle size={12} className="text-rpg-green shrink-0" />
: q.playerStatus === 'completed'
? <Trophy size={12} className="text-rpg-gold shrink-0" />
: q.playerStatus === 'active'
? <Swords size={12} className="text-rpg-blue shrink-0" />
: <Circle size={11} className="text-[#3a4560] shrink-0" />
}
<div className="flex-1 min-w-0">
<span className={
q.playerStatus === 'claimed' ? 'text-rpg-green' : q.playerStatus === 'active' ? 'text-rpg-text' : 'text-rpg-muted'
}>{q.name}</span>
{q.playerStatus === 'active' && (
<span className="text-[10px] text-rpg-blue ml-1.5">{q.progress}/{q.objectiveCount}</span>
)}
</div>
<span className="text-[10px] text-rpg-muted">{q.rewardXp} XP</span>
{q.minLevel > 1 && !q.levelOk && <span className="text-[9px] text-rpg-red">Niv.{q.minLevel}</span>}
{/* Actions */}
{q.canAccept && (
<button className="btn btn-ghost text-[10px] py-px px-1.5" disabled={acceptMut.isPending} onClick={() => acceptMut.mutate()}>
{acceptMut.isPending ? '...' : '+ Accepter'}
</button>
)}
{q.playerStatus === 'completed' && (
<button className="btn btn-gold text-[10px] py-px px-1.5" disabled={claimMut.isPending} onClick={() => claimMut.mutate()}>
{claimMut.isPending ? '...' : '🎁 Réclamer'}
</button>
)}
{acceptMut.isError && <span className="text-rpg-red text-[9px]">{(acceptMut.error as Error).message}</span>}
</div>
);
}
/** Détermine si un arc doit être ouvert par défaut */
function shouldArcBeOpen(arc: any): boolean {
if (!arc.zoneUnlocked) return false;
if (arc.completed) return false;
// Ouvert si au moins une quête est active ou prête à réclamer
return arc.quests.some((q: any) => q.playerStatus === 'active' || q.playerStatus === 'completed');
}
function ArcSection({ arc, defaultOpen }: { arc: any; defaultOpen: boolean }) {
const [open, setOpen] = useState(defaultOpen);
const { completed, total } = arc.progress;
const locked = !arc.zoneUnlocked;
const pct = total > 0 ? Math.floor((completed / total) * 100) : 0;
return (
<div className={`card ${locked ? '' : arc.completed ? '' : 'card-gold'} py-3 px-4 mb-2 ${locked ? 'opacity-40' : ''}`}>
<div
className={`flex items-center gap-2 cursor-pointer ${open && !locked ? 'mb-2' : ''}`}
onClick={() => setOpen(!open)}
>
{locked ? <Lock size={14} className="text-rpg-muted shrink-0" /> : open ? <ChevronDown size={14} className="text-rpg-muted shrink-0" /> : <ChevronRight size={14} className="text-rpg-muted shrink-0" />}
<Scroll size={14} className={`shrink-0 ${arc.completed ? 'text-rpg-green' : locked ? 'text-rpg-muted' : 'text-rpg-gold'}`} />
<span className={`font-bold text-sm flex-1 ${arc.completed ? 'text-rpg-green' : locked ? 'text-rpg-muted' : 'text-rpg-gold'}`}>
{arc.name}
</span>
<span className="text-[11px] text-rpg-muted">{completed}/{total}</span>
{arc.completed && <CheckCircle size={14} className="text-rpg-green shrink-0" />}
{locked && <span className="text-[10px] text-rpg-muted">🔒 Complétez l'arc précédent</span>}
</div>
{/* Progress bar */}
{!locked && (
<div className="bar-track mb-2" style={{ height: 4 }}>
<div className={arc.completed ? 'bar-fill-hp' : 'bar-fill-xp'} style={{ width: `${pct}%`, background: arc.completed ? '#3ddc84' : undefined }} />
</div>
)}
{open && !locked && (
<>
<p className="text-[11px] text-rpg-muted mb-2 pl-7">{arc.description}</p>
<div className="flex flex-col pl-3">
{arc.quests.map((q: any) => <ArcQuestRow key={q.id} q={q} />)}
</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 });
const [showAllCombat, setShowAllCombat] = useState(false);
// Pré-calculer quels arcs sont ouverts par défaut (stable entre renders)
const arcDefaultOpen = useMemo(() => {
if (!arcs) return {};
const map: Record<string, boolean> = {};
for (const arc of arcs) {
map[arc.id] = shouldArcBeOpen(arc);
}
return map;
}, [arcs]);
if (loadActive || loadAvail) return <div className="p-8 text-rpg-muted">Chargement…</div>;
const isCraftQuest = (q: any) => ['forge_item', 'craft_item'].includes(q.objectiveType ?? q.quest?.objectiveType);
const isCombatQuest = (q: any) => !isCraftQuest(q);
const activeAll = active ?? [];
const activeCombat = activeAll.filter((pq: any) => !pq.quest.repeatable && isCombatQuest(pq));
const activeCraft = activeAll.filter((pq: any) => !pq.quest.repeatable && isCraftQuest(pq));
const activeDaily = activeAll.filter((pq: any) => pq.quest.repeatable);
const availableAll = available ?? [];
const availableCombat = availableAll.filter((q: any) => !q.repeatable && isCombatQuest(q));
const availableCraft = availableAll.filter((q: any) => !q.repeatable && isCraftQuest(q));
const availableDaily = availableAll.filter((q: any) => q.repeatable);
const shownCombat = showAllCombat ? availableCombat : availableCombat.slice(0, 3);
const hiddenCount = availableCombat.length - 3;
return (
<div>
<h2 className="mb-4 text-rpg-gold text-xl font-bold">📜 Quêtes</h2>
<div className="grid-2">
{/* Active combat quests */}
<div>
<p className="mb-2 text-xs font-bold text-rpg-muted uppercase">
Quêtes actives ({activeCombat.length}/3)
</p>
{activeCombat.length > 0 ? (
<div className="flex flex-col gap-1.5">
{activeCombat.map((pq: any) => <QuestCard key={pq.id} pq={pq} mode="active" />)}
</div>
) : (
<div className="card py-6 text-center text-rpg-muted text-[13px]">
Aucune quête active — acceptez-en à droite
</div>
)}
</div>
{/* Available combat quests */}
<div>
<p className="mb-2 text-xs font-bold text-rpg-muted uppercase">
Quêtes de combat
</p>
{shownCombat.length > 0 ? (
<div className="flex flex-col gap-1.5">
{shownCombat.map((q: any) => <QuestCard key={q.id} pq={q} mode="available" />)}
{hiddenCount > 0 && (
<button
className="btn btn-ghost w-full text-[11px] py-1 mt-0.5"
onClick={() => setShowAllCombat(!showAllCombat)}
>
{showAllCombat ? 'Réduire' : `Voir tout (+${hiddenCount} quête${hiddenCount > 1 ? 's' : ''})`}
</button>
)}
</div>
) : (
<div className="card py-6 text-center text-rpg-muted text-[13px]">
Toutes les quêtes de combat sont complétées
</div>
)}
</div>
</div>
{/* Métiers */}
{(activeCraft.length > 0 || availableCraft.length > 0) && (
<div className="mt-6">
<p className="mb-2 text-xs font-bold text-rpg-muted uppercase">🔨 Métiers</p>
<div className="grid-2-cards">
{activeCraft.map((pq: any) => <QuestCard key={pq.id} pq={pq} mode="active" />)}
{availableCraft.map((q: any) => <QuestCard key={q.id} pq={q} mode="available" />)}
</div>
</div>
)}
{/* Tâches quotidiennes */}
<div className="mt-6">
<p className="mb-2 text-xs font-bold text-rpg-muted uppercase">🔄 Tâches quotidiennes</p>
<div className="grid-2-cards">
{activeDaily.map((pq: any) => <QuestCard key={pq.id} pq={pq} mode="active" />)}
{availableDaily.map((q: any) => <QuestCard key={q.id} pq={q} mode="available" />)}
</div>
</div>
{/* Arcs narratifs */}
{arcs && arcs.length > 0 && (
<div className="mt-6">
<p className="mb-2 text-xs font-bold text-rpg-muted uppercase">📖 Arcs narratifs</p>
{arcs.map((arc: any) => (
<ArcSection key={arc.id} arc={arc} defaultOpen={arcDefaultOpen[arc.id] ?? false} />
))}
</div>
)}
</div>
);
}