o2o-castad-frontend/src/components/Tutorial/useTutorial.ts

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,
};
}