186 lines
6.0 KiB
TypeScript
186 lines
6.0 KiB
TypeScript
import React, { useState, useCallback } from 'react';
|
|
import { tutorialSteps, TutorialHint, TUTORIAL_PAGE_GROUPS } from './tutorialSteps';
|
|
|
|
// 현재 키가 속한 그룹의 전체 힌트 수와 현재까지의 offset 반환
|
|
function getGroupProgress(key: string, currentIndex: number): { groupTotal: number; groupOffset: number; isLastKeyInGroup: boolean } | null {
|
|
const group = TUTORIAL_PAGE_GROUPS.find(g => g.includes(key));
|
|
if (!group) return null;
|
|
let offset = 0;
|
|
let total = 0;
|
|
for (const k of group) {
|
|
const step = tutorialSteps.find(s => s.key === k);
|
|
const count = step?.hints.length ?? 0;
|
|
if (k === key) offset = total;
|
|
total += count;
|
|
}
|
|
const isLastKeyInGroup = group[group.length - 1] === key;
|
|
return { groupTotal: total, groupOffset: offset + currentIndex, isLastKeyInGroup };
|
|
}
|
|
|
|
const SEEN_KEY = 'ado2_tutorial_seen';
|
|
const PROGRESS_KEY = 'ado2_tutorial_progress';
|
|
|
|
// 전역 단일 활성 튜토리얼 관리 — 새 튜토리얼 시작 시 이전 것을 skip 처리
|
|
let globalSkip: (() => void) | null = null;
|
|
|
|
function getSeenKeys(): string[] {
|
|
try {
|
|
return JSON.parse(localStorage.getItem(SEEN_KEY) || '[]');
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function markSeen(key: string) {
|
|
const seen = getSeenKeys();
|
|
if (!seen.includes(key)) {
|
|
localStorage.setItem(SEEN_KEY, JSON.stringify([...seen, key]));
|
|
}
|
|
clearProgress(key);
|
|
}
|
|
|
|
function saveProgress(key: string, index: number) {
|
|
try {
|
|
const progress = JSON.parse(localStorage.getItem(PROGRESS_KEY) || '{}');
|
|
progress[key] = index;
|
|
localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress));
|
|
} catch {}
|
|
}
|
|
|
|
function loadProgress(key: string): number {
|
|
try {
|
|
const progress = JSON.parse(localStorage.getItem(PROGRESS_KEY) || '{}');
|
|
return progress[key] ?? 0;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
function clearProgress(key: string) {
|
|
try {
|
|
const progress = JSON.parse(localStorage.getItem(PROGRESS_KEY) || '{}');
|
|
delete progress[key];
|
|
localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress));
|
|
} catch {}
|
|
}
|
|
|
|
interface UseTutorialReturn {
|
|
isActive: boolean;
|
|
isRestartPopupVisible: boolean;
|
|
currentHintIndex: number;
|
|
hints: TutorialHint[];
|
|
tutorialKey: string | null;
|
|
groupProgress: { groupTotal: number; groupOffset: number } | null;
|
|
startTutorial: (key: string, onComplete?: () => void, forceFromStart?: boolean) => void;
|
|
nextHint: () => void;
|
|
prevHint: () => void;
|
|
skipTutorial: () => void;
|
|
showRestartPopup: (key: string) => void;
|
|
confirmRestart: () => void;
|
|
cancelRestart: () => void;
|
|
hasSeen: (key: string) => boolean;
|
|
}
|
|
|
|
export function useTutorial(): UseTutorialReturn {
|
|
const [isActive, setIsActive] = useState(false);
|
|
const [isRestartPopupVisible, setIsRestartPopupVisible] = useState(false);
|
|
const [pendingRestartKey, setPendingRestartKey] = useState<string | null>(null);
|
|
const [currentHintIndex, setCurrentHintIndex] = useState(0);
|
|
const [hints, setHints] = useState<TutorialHint[]>([]);
|
|
const [tutorialKey, setTutorialKey] = useState<string | null>(null);
|
|
const onCompleteRef = React.useRef<(() => void) | undefined>(undefined);
|
|
|
|
const startTutorial = useCallback((key: string, onComplete?: () => void, forceFromStart?: boolean) => {
|
|
const step = tutorialSteps.find(s => s.key === key);
|
|
if (!step || step.hints.length === 0) return;
|
|
// 다른 인스턴스에서 활성화된 튜토리얼이 있으면 skip 처리
|
|
globalSkip?.();
|
|
const savedIndex = forceFromStart ? 0 : loadProgress(key);
|
|
const resumeIndex = savedIndex < step.hints.length ? savedIndex : 0;
|
|
onCompleteRef.current = onComplete;
|
|
setHints(step.hints);
|
|
setTutorialKey(key);
|
|
setCurrentHintIndex(resumeIndex);
|
|
setIsActive(true);
|
|
// 이 인스턴스의 skip을 전역에 등록
|
|
globalSkip = () => {
|
|
if (key) saveProgress(key, resumeIndex);
|
|
setIsActive(false);
|
|
setCurrentHintIndex(0);
|
|
globalSkip = null;
|
|
};
|
|
}, []);
|
|
|
|
const nextHint = useCallback(() => {
|
|
setCurrentHintIndex(prev => {
|
|
if (prev < hints.length - 1) {
|
|
const next = prev + 1;
|
|
if (tutorialKey) saveProgress(tutorialKey, next);
|
|
return next;
|
|
}
|
|
// 마지막 힌트 완료 → seen 기록 + 진행 상태 삭제
|
|
setIsActive(false);
|
|
if (tutorialKey) markSeen(tutorialKey);
|
|
globalSkip = null; // 완료된 튜토리얼은 globalSkip 해제
|
|
onCompleteRef.current?.();
|
|
onCompleteRef.current = undefined;
|
|
return 0;
|
|
});
|
|
}, [hints.length, tutorialKey]);
|
|
|
|
const prevHint = useCallback(() => {
|
|
setCurrentHintIndex(prev => Math.max(0, prev - 1));
|
|
}, []);
|
|
|
|
// 건너뛰기: 현재 진행 인덱스 저장 후 오버레이 닫기 → 다음 방문 시 이어서 표시
|
|
const skipTutorial = useCallback(() => {
|
|
if (tutorialKey) saveProgress(tutorialKey, currentHintIndex);
|
|
setIsActive(false);
|
|
setCurrentHintIndex(0);
|
|
}, [tutorialKey, currentHintIndex]);
|
|
|
|
// 튜토리얼 다시 보기: 팝업 표시만
|
|
const showRestartPopup = useCallback((key: string) => {
|
|
setPendingRestartKey(key);
|
|
setIsRestartPopupVisible(true);
|
|
}, []);
|
|
|
|
// 팝업에서 확인 → seen/progress 초기화 후 튜토리얼 시작
|
|
const confirmRestart = useCallback(() => {
|
|
setIsRestartPopupVisible(false);
|
|
if (pendingRestartKey) {
|
|
localStorage.removeItem(SEEN_KEY);
|
|
localStorage.removeItem(PROGRESS_KEY);
|
|
startTutorial(pendingRestartKey, undefined, true);
|
|
}
|
|
setPendingRestartKey(null);
|
|
}, [pendingRestartKey, startTutorial]);
|
|
|
|
// 팝업에서 취소
|
|
const cancelRestart = useCallback(() => {
|
|
setIsRestartPopupVisible(false);
|
|
setPendingRestartKey(null);
|
|
}, []);
|
|
|
|
const hasSeen = useCallback((key: string) => {
|
|
return getSeenKeys().includes(key);
|
|
}, []);
|
|
|
|
return {
|
|
isActive,
|
|
isRestartPopupVisible,
|
|
currentHintIndex,
|
|
hints,
|
|
tutorialKey,
|
|
groupProgress: tutorialKey ? getGroupProgress(tutorialKey, currentHintIndex) : null,
|
|
startTutorial,
|
|
nextHint,
|
|
prevHint,
|
|
skipTutorial,
|
|
showRestartPopup,
|
|
confirmRestart,
|
|
cancelRestart,
|
|
hasSeen,
|
|
};
|
|
}
|