Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 48s
- 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>
324 lines
14 KiB
TypeScript
324 lines
14 KiB
TypeScript
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>
|
||
);
|
||
}
|