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
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:
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { questApi } from '../api/endpoints';
|
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';
|
import { useState } from 'react';
|
||||||
|
|
||||||
const OBJ_LABELS: Record<string, string> = {
|
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 }) {
|
function ArcSection({ arc }: { arc: any }) {
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
const { completed, total } = arc.progress;
|
const { completed, total } = arc.progress;
|
||||||
|
const locked = !arc.zoneUnlocked;
|
||||||
|
|
||||||
return (
|
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
|
<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)}
|
onClick={() => setOpen(!open)}
|
||||||
>
|
>
|
||||||
{open ? <ChevronDown size={14} color="#6b7a99" /> : <ChevronRight size={14} color="#6b7a99" />}
|
{locked ? <Lock size={14} color="#6b7a99" /> : open ? <ChevronDown size={14} color="#6b7a99" /> : <ChevronRight size={14} color="#6b7a99" />}
|
||||||
<Scroll size={14} color={arc.completed ? '#3ddc84' : '#f4c94e'} />
|
<Scroll size={14} color={arc.completed ? '#3ddc84' : locked ? '#6b7a99' : '#f4c94e'} />
|
||||||
<span style={{ fontWeight: 700, fontSize: 14, color: arc.completed ? '#3ddc84' : '#f4c94e', flex: 1 }}>{arc.name}</span>
|
<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>
|
<span style={{ fontSize: 11, color: '#6b7a99' }}>{completed}/{total}</span>
|
||||||
{arc.completed && <CheckCircle size={14} color="#3ddc84" />}
|
{arc.completed && <CheckCircle size={14} color="#3ddc84" />}
|
||||||
|
{locked && <span style={{ fontSize: 10, color: '#6b7a99' }}>🔒 Complétez l'arc précédent</span>}
|
||||||
</div>
|
</div>
|
||||||
{open && (
|
{open && !locked && (
|
||||||
<>
|
<>
|
||||||
<p style={{ fontSize: 11, color: '#6b7a99', margin: '0 0 8px', paddingLeft: 28 }}>{arc.description}</p>
|
<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 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', paddingLeft: 12 }}>
|
||||||
{arc.quests.map((q: any) => (
|
{arc.quests.map((q: any) => <ArcQuestRow key={q.id} q={q} />)}
|
||||||
<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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -50,9 +50,8 @@ export class QuestService {
|
|||||||
|
|
||||||
return quests.filter((q) => {
|
return quests.filter((q) => {
|
||||||
if (q.minLevel > character.level) return false;
|
if (q.minLevel > character.level) return false;
|
||||||
|
// Arc quests managed from arc panel — not in available
|
||||||
// Zone filter: if quest belongs to an arc with a zone, check zone is unlocked
|
if (q.arcId) return false;
|
||||||
if (q.arc?.zone && !unlockedZones.includes(q.arc.zone)) return false;
|
|
||||||
|
|
||||||
const status = questStatusMap.get(q.id);
|
const status = questStatusMap.get(q.id);
|
||||||
if (status === 'active' || status === 'completed') return false;
|
if (status === 'active' || status === 'completed') return false;
|
||||||
@@ -287,6 +286,11 @@ export class QuestService {
|
|||||||
// --- Arcs ---
|
// --- Arcs ---
|
||||||
|
|
||||||
async getArcs(characterId: string) {
|
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({
|
const arcs = await this.arcRepo.find({
|
||||||
relations: ['quests'],
|
relations: ['quests'],
|
||||||
order: { sortOrder: 'ASC' },
|
order: { sortOrder: 'ASC' },
|
||||||
@@ -297,24 +301,35 @@ export class QuestService {
|
|||||||
|
|
||||||
const playerQuests = await this.playerQuestRepo.find({
|
const playerQuests = await this.playerQuestRepo.find({
|
||||||
where: { characterId },
|
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) => ({
|
return arcs.map((arc) => {
|
||||||
...arc,
|
const zoneUnlocked = !arc.zone || unlockedZones.includes(arc.zone);
|
||||||
completed: arcMap.get(arc.id)?.completed ?? false,
|
return {
|
||||||
completedAt: arcMap.get(arc.id)?.completedAt ?? null,
|
...arc,
|
||||||
quests: arc.quests
|
zoneUnlocked,
|
||||||
.sort((a, b) => a.arcOrder - b.arcOrder)
|
completed: arcMap.get(arc.id)?.completed ?? false,
|
||||||
.map((q) => ({
|
completedAt: arcMap.get(arc.id)?.completedAt ?? null,
|
||||||
...q,
|
quests: arc.quests
|
||||||
playerStatus: questStatusMap.get(q.id) ?? 'available',
|
.sort((a, b) => a.arcOrder - b.arcOrder)
|
||||||
})),
|
.map((q) => {
|
||||||
progress: {
|
const pq = questDataMap.get(q.id);
|
||||||
completed: arc.quests.filter((q) => questStatusMap.get(q.id) === 'claimed').length,
|
return {
|
||||||
total: arc.quests.length,
|
...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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user