Files
echoes-of-the-ash/pwa/src/components/game/DialogModal.tsx
2026-02-08 20:18:42 +01:00

298 lines
9.5 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { useGame } from '../../contexts/GameContext';
import { GAME_API_URL } from '../../config';
import { GameModal } from './GameModal';
import { GameButton } from '../common/GameButton';
import { getAssetPath } from '../../utils/assetPath';
import './DialogModal.css';
interface DialogModalProps {
npcId: string;
npcData: any;
onClose: () => void;
onTrade?: () => void;
}
interface Topic {
id: string;
title: { [key: string]: string } | string;
text: { [key: string]: string } | string;
}
interface Quest {
quest_id: string;
title: { [key: string]: string } | string;
description: { [key: string]: string } | string;
giver_id: string;
objectives: any[];
repeatable?: boolean;
type?: 'individual' | 'global';
// Logic for frontend state
status?: 'available' | 'active' | 'completed' | 'can_turn_in';
}
export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClose, onTrade }) => {
const { token, locale, actions } = useGame();
const [dialogData, setDialogData] = useState<any>(null);
const [currentText, setCurrentText] = useState<string>("");
const [quests, setQuests] = useState<Quest[]>([]);
const [viewState, setViewState] = useState<'greeting' | 'topic' | 'quest_preview'>('greeting');
const [selectedQuest, setSelectedQuest] = useState<Quest | null>(null);
// Fetch dialog and quests
useEffect(() => {
const fetchData = async () => {
if (!token || !npcId) return;
try {
// 1. Fetch Dialog
const dialogRes = await fetch(`${GAME_API_URL}/npcs/${npcId}/dialog`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const dialog = await dialogRes.json();
setDialogData(dialog);
// Initial greeting
const greeting = getLocalized(dialog.greeting) || "Hello.";
setCurrentText(greeting);
// 2. Fetch Available Quests (Starts)
const availRes = await fetch(`${GAME_API_URL}/quests/available`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const availableQuests = await availRes.json();
// 3. Fetch Active Quests (Turn-ins)
const activeRes = await fetch(`${GAME_API_URL}/quests/active`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const activeQuests = await activeRes.json();
// Filter and Merge for this NPC
const npcQuests: Quest[] = [];
// Add available quests from this NPC
if (Array.isArray(availableQuests)) {
availableQuests.forEach((q: any) => {
if (q.giver_id === npcId) {
npcQuests.push({ ...q, status: 'available' });
}
});
}
// Add active quests from this NPC
if (Array.isArray(activeQuests)) {
activeQuests.forEach((q: any) => {
if (q.giver_id === npcId && q.status === 'active') {
npcQuests.push({ ...q, status: 'active' });
}
});
}
setQuests(npcQuests);
} catch (e) {
console.error("Error fetching NPC data", e);
}
};
fetchData();
}, [npcId, token, locale]);
const getLocalized = (obj: any) => {
if (typeof obj === 'string') return obj;
return obj?.[locale] || obj?.['en'] || "";
};
const handleTopicClick = (topic: Topic) => {
const text = getLocalized(topic.text) || "...";
setCurrentText(text);
setViewState('topic');
};
const handleQuestClick = (quest: Quest) => {
setSelectedQuest(quest);
const desc = getLocalized(quest.description);
if (quest.status === 'active') {
setCurrentText(desc + "\n\n(Quest in progress...)");
} else {
setCurrentText(desc);
}
setViewState('quest_preview');
};
const acceptQuest = async () => {
if (!selectedQuest) return;
try {
const res = await fetch(`${GAME_API_URL}/quests/accept/${selectedQuest.quest_id}`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
// Refresh or update state
setCurrentText("Quest accepted! Good luck.");
if (data.quest) {
actions.handleQuestUpdate(data.quest);
}
setTimeout(() => {
setViewState('greeting');
// Remove from available, add to active locally (simplification)
setQuests(prev => prev.map(q => q.quest_id === selectedQuest.quest_id ? { ...q, status: 'active' } : q));
setSelectedQuest(null);
resetToGreeting();
}, 1500);
} else {
const err = await res.json();
alert(err.detail);
}
} catch (e) {
console.error(e);
}
};
const handInQuest = async () => {
if (!selectedQuest) return;
try {
const res = await fetch(`${GAME_API_URL}/quests/hand_in/${selectedQuest.quest_id}`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
});
const result = await res.json();
if (res.ok) {
if (result.quest_update) {
actions.handleQuestUpdate(result.quest_update);
}
// Refresh game data to update inventory/stats
actions.fetchGameData();
if (result.is_completed) {
let msg = getLocalized(result.completion_text) || "Thank you!";
if (result.rewards && result.rewards.length > 0) {
msg += "\n\nRewards:\n" + result.rewards.join('\n');
}
setCurrentText(msg);
// Remove from list
setQuests(prev => prev.filter(q => q.quest_id !== selectedQuest.quest_id));
} else {
setCurrentText(`Progress updated.\n${result.items_deducted?.join('\n')}`);
}
setTimeout(() => {
resetToGreeting();
}, 2000);
} else {
alert(result.detail);
}
} catch (e) {
console.error(e);
}
};
const resetToGreeting = () => {
if (!dialogData) return;
const greeting = getLocalized(dialogData.greeting) || "Hello.";
setCurrentText(greeting);
setViewState('greeting');
setSelectedQuest(null);
};
if (!dialogData) return null;
const npcName = getLocalized(npcData?.name) || "Unknown";
return (
<GameModal
title={npcName}
onClose={onClose}
className="dialog-modal"
>
<div className="npc-dialog-layout">
<div className="npc-portrait-container">
<img
className="npc-portrait"
src={npcData.image ? getAssetPath(npcData.image) : ''}
alt={npcName}
/>
</div>
<div className="npc-dialog-content">
<div className="dialogue-box">
<p>{currentText}</p>
</div>
<div className="options-grid">
{/* BACK BUTTON */}
{(viewState === 'topic' || viewState === 'quest_preview') && (
<GameButton className="option-btn" onClick={resetToGreeting}>
&larr; Back
</GameButton>
)}
{/* NPC TOPICS */}
{viewState === 'greeting' && dialogData.topics?.map((topic: Topic) => (
<GameButton key={topic.id} className="option-btn" onClick={() => handleTopicClick(topic)}>
💬 {getLocalized(topic.title)}
</GameButton>
))}
{/* QUESTS */}
{viewState === 'greeting' && quests.map(q => (
<GameButton
key={q.quest_id}
className="option-btn quest-btn"
onClick={() => handleQuestClick(q)}
variant={q.status === 'active' ? 'warning' : 'info'}
>
{q.status === 'available' ? '❗' : '❓'} {getLocalized(q.title)}
</GameButton>
))}
{/* CONFIRM QUEST ACTION */}
{viewState === 'quest_preview' && selectedQuest?.status === 'available' && (
<div style={{ gridColumn: 'span 2' }}>
<GameButton className="option-btn action-btn" variant="success" onClick={acceptQuest} style={{ width: '100%' }}>
Accept Quest
</GameButton>
</div>
)}
{viewState === 'quest_preview' && selectedQuest?.status === 'active' && (
<div style={{ gridColumn: 'span 2' }}>
<GameButton
className="option-btn action-btn"
variant="warning"
onClick={handInQuest}
style={{ width: '100%' }}
>
{/* If it's pure kill quest, 'Complete' makes more sense than 'Hand In' */}
{selectedQuest.objectives?.some((o: any) => o.type === 'kill_count') && !selectedQuest.objectives?.some((o: any) => o.type === 'item_delivery')
? "Complete Quest"
: "Hand In Items"}
</GameButton>
</div>
)}
{/* TRADE - Only show in greeting */}
{viewState === 'greeting' && npcData.trade?.enabled && (
<GameButton className="option-btn trade-btn" variant="success" onClick={onTrade}>
💰 Trade
</GameButton>
)}
{/* EXIT - Span full width */}
{viewState === 'greeting' && (
<GameButton className="option-btn exit-btn" variant="secondary" onClick={onClose} style={{ gridColumn: 'span 2' }}>
Goodbye
</GameButton>
)}
</div>
</div>
</div>
</GameModal>
);
};