feat: arc quests accept from arc panel + side quests only in available
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 35s

Arc panel: boutons Accepter/Réclamer directement sur chaque quête d'arc,
progress affiché (3/5), arcs lockés avec 🔒 et opacity réduite.
Quêtes disponibles: seulement les secondaires (pas les arcs).
Quêtes d'arc abandonnées: ré-acceptables depuis le panel arc.
Zone locking respecté dans getArcs (zoneUnlocked flag).
This commit is contained in:
2026-03-24 18:31:42 +01:00
parent 810ad5ee64
commit 9aadc326e1
2 changed files with 107 additions and 46 deletions

View File

@@ -1,6 +1,6 @@
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 { Scroll, CheckCircle, Circle, Trophy, ChevronDown, ChevronRight, Star, Coins, Swords, Lock } from 'lucide-react';
import { useState } from 'react';
const OBJ_LABELS: Record<string, string> = {
@@ -116,44 +116,90 @@ function QuestCard({ pq, mode }: { pq: any; mode: 'active' | 'available' | 'comp
);
}
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 style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, padding: '4px 0', borderBottom: '1px solid #1a2030' }}>
{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" />
}
<div style={{ flex: 1 }}>
<span style={{
color: q.playerStatus === 'claimed' ? '#3ddc84' : q.playerStatus === 'active' ? '#dce4f0' : '#6b7a99',
}}>{q.name}</span>
{q.playerStatus === 'active' && (
<span style={{ fontSize: 10, color: '#5ba4f5', marginLeft: 6 }}>{q.progress}/{q.objectiveCount}</span>
)}
</div>
<span style={{ fontSize: 10, color: '#6b7a99' }}>{q.rewardXp} XP</span>
{q.minLevel > 1 && !q.levelOk && <span style={{ fontSize: 9, color: '#e84040' }}>Niv.{q.minLevel}</span>}
{/* Actions */}
{q.canAccept && (
<button className="btn btn-ghost" style={{ fontSize: 10, padding: '0.1rem 0.4rem' }}
disabled={acceptMut.isPending} onClick={() => acceptMut.mutate()}>
{acceptMut.isPending ? '...' : '+ Accepter'}
</button>
)}
{q.playerStatus === 'completed' && (
<button className="btn btn-gold" style={{ fontSize: 10, padding: '0.1rem 0.4rem' }}
disabled={claimMut.isPending} onClick={() => claimMut.mutate()}>
{claimMut.isPending ? '...' : '🎁 Réclamer'}
</button>
)}
{acceptMut.isError && <span style={{ color: '#e84040', fontSize: 9 }}>{(acceptMut.error as Error).message}</span>}
</div>
);
}
function ArcSection({ arc }: { arc: any }) {
const [open, setOpen] = useState(true);
const { completed, total } = arc.progress;
const locked = !arc.zoneUnlocked;
return (
<div className="card" style={{ padding: '0.75rem 1rem', marginBottom: '0.5rem' }}>
<div className={`card ${locked ? '' : arc.completed ? '' : 'card-gold'}`} style={{ padding: '0.75rem 1rem', marginBottom: '0.5rem', opacity: locked ? 0.4 : 1 }}>
<div
style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginBottom: open ? 8 : 0 }}
style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginBottom: open && !locked ? 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>
{locked ? <Lock size={14} color="#6b7a99" /> : open ? <ChevronDown size={14} color="#6b7a99" /> : <ChevronRight size={14} color="#6b7a99" />}
<Scroll size={14} color={arc.completed ? '#3ddc84' : locked ? '#6b7a99' : '#f4c94e'} />
<span style={{ fontWeight: 700, fontSize: 14, color: arc.completed ? '#3ddc84' : locked ? '#6b7a99' : '#f4c94e', flex: 1 }}>
{arc.name}
</span>
<span style={{ fontSize: 11, color: '#6b7a99' }}>{completed}/{total}</span>
{arc.completed && <CheckCircle size={14} color="#3ddc84" />}
{locked && <span style={{ fontSize: 10, color: '#6b7a99' }}>🔒 Complétez l'arc précédent</span>}
</div>
{open && (
{open && !locked && (
<>
<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 style={{ display: 'flex', flexDirection: 'column', paddingLeft: 12 }}>
{arc.quests.map((q: any) => <ArcQuestRow key={q.id} q={q} />)}
</div>
</>
)}

View File

@@ -50,9 +50,8 @@ export class QuestService {
return quests.filter((q) => {
if (q.minLevel > character.level) return false;
// Zone filter: if quest belongs to an arc with a zone, check zone is unlocked
if (q.arc?.zone && !unlockedZones.includes(q.arc.zone)) return false;
// Arc quests managed from arc panel — not in available
if (q.arcId) return false;
const status = questStatusMap.get(q.id);
if (status === 'active' || status === 'completed') return false;
@@ -287,6 +286,11 @@ export class QuestService {
// --- Arcs ---
async getArcs(characterId: string) {
const character = await this.characterRepo.findOne({ where: { id: characterId } });
const playerLevel = character?.level ?? 1;
const unlockedZones = await getUnlockedZones(characterId, this.arcRepo, this.playerArcRepo);
const arcs = await this.arcRepo.find({
relations: ['quests'],
order: { sortOrder: 'ASC' },
@@ -297,24 +301,35 @@ export class QuestService {
const playerQuests = await this.playerQuestRepo.find({
where: { characterId },
select: ['questId', 'status'],
select: ['questId', 'status', 'id', 'progress'],
});
const questStatusMap = new Map(playerQuests.map((pq) => [pq.questId, pq.status]));
const questDataMap = new Map(playerQuests.map((pq) => [pq.questId, pq]));
return arcs.map((arc) => ({
...arc,
completed: arcMap.get(arc.id)?.completed ?? false,
completedAt: arcMap.get(arc.id)?.completedAt ?? null,
quests: arc.quests
.sort((a, b) => a.arcOrder - b.arcOrder)
.map((q) => ({
...q,
playerStatus: questStatusMap.get(q.id) ?? 'available',
})),
progress: {
completed: arc.quests.filter((q) => questStatusMap.get(q.id) === 'claimed').length,
total: arc.quests.length,
},
}));
return arcs.map((arc) => {
const zoneUnlocked = !arc.zone || unlockedZones.includes(arc.zone);
return {
...arc,
zoneUnlocked,
completed: arcMap.get(arc.id)?.completed ?? false,
completedAt: arcMap.get(arc.id)?.completedAt ?? null,
quests: arc.quests
.sort((a, b) => a.arcOrder - b.arcOrder)
.map((q) => {
const pq = questDataMap.get(q.id);
return {
...q,
playerStatus: pq?.status ?? 'available',
playerQuestId: pq?.id ?? null,
progress: pq?.progress ?? 0,
canAccept: zoneUnlocked && !pq && q.minLevel <= playerLevel,
levelOk: q.minLevel <= playerLevel,
};
}),
progress: {
completed: arc.quests.filter((q) => questDataMap.get(q.id)?.status === 'claimed').length,
total: arc.quests.length,
},
};
});
}
}