튜토리얼 수정

feature-tutorial
김성경 2026-04-14 14:17:05 +09:00
parent 8734d3388d
commit cef0919879
7 changed files with 84 additions and 57 deletions

View File

@ -16,6 +16,8 @@ export interface TutorialStepDef {
export const TUTORIAL_KEYS = { export const TUTORIAL_KEYS = {
LANDING: 'landing', LANDING: 'landing',
LANDING_URL: 'landingUrl',
LANDING_NAME: 'landingName',
ANALYSIS: 'analysis', ANALYSIS: 'analysis',
ASSET: 'asset', ASSET: 'asset',
SOUND: 'sound', SOUND: 'sound',
@ -38,17 +40,47 @@ export const tutorialSteps: TutorialStepDef[] = [
hints: [ hints: [
{ {
targetSelector: '.hero-dropdown-container', targetSelector: '.hero-dropdown-container',
titleKey: 'tutorial.landing.intro.title',
descriptionKey: 'tutorial.landing.intro.desc',
position: 'top',
clickToAdvance: true,
},
{
targetSelector: '.hero-dropdown-menu',
titleKey: 'tutorial.landing.dropdown.title', titleKey: 'tutorial.landing.dropdown.title',
descriptionKey: 'tutorial.landing.dropdown.desc', descriptionKey: 'tutorial.landing.dropdown.desc',
position: 'top', position: 'top',
clickToAdvance: false, clickToAdvance: true,
spotlightPaddingOverride: { top: 10, right: 15, bottom: 100, left: 0 },
}, },
],
},
{
key: TUTORIAL_KEYS.LANDING_URL,
hints: [
{ {
targetSelector: '.hero-input-wrapper', targetSelector: '.hero-input-wrapper',
titleKey: 'tutorial.landing.field.title', titleKey: 'tutorial.landing.url.title',
descriptionKey: 'tutorial.landing.field.desc', descriptionKey: 'tutorial.landing.url.desc',
position: 'right', position: 'bottom',
clickToAdvance: false,
},
{
targetSelector: '.hero-button',
titleKey: 'tutorial.landing.button.title',
descriptionKey: 'tutorial.landing.button.desc',
position: 'bottom',
clickToAdvance: true,
},
],
},
{
key: TUTORIAL_KEYS.LANDING_NAME,
hints: [
{
targetSelector: '.hero-input-wrapper',
titleKey: 'tutorial.landing.name.title',
descriptionKey: 'tutorial.landing.name.desc',
position: 'top',
clickToAdvance: false, clickToAdvance: false,
spotlightPaddingOverride: { top: 0, right: 0, bottom: 290, left: -105 }, spotlightPaddingOverride: { top: 0, right: 0, bottom: 290, left: -105 },
}, },
@ -149,7 +181,6 @@ export const tutorialSteps: TutorialStepDef[] = [
descriptionKey: 'tutorial.sound.language.desc', descriptionKey: 'tutorial.sound.language.desc',
position: 'top', position: 'top',
clickToAdvance: false, clickToAdvance: false,
spotlightPaddingOverride: { bottom: 230 },
}, },
{ {
targetSelector: '.btn-generate-sound', targetSelector: '.btn-generate-sound',

View File

@ -54,7 +54,7 @@ interface UseTutorialReturn {
currentHintIndex: number; currentHintIndex: number;
hints: TutorialHint[]; hints: TutorialHint[];
tutorialKey: string | null; tutorialKey: string | null;
startTutorial: (key: string, onComplete?: () => void) => void; startTutorial: (key: string, onComplete?: () => void, forceFromStart?: boolean) => void;
nextHint: () => void; nextHint: () => void;
prevHint: () => void; prevHint: () => void;
skipTutorial: () => void; skipTutorial: () => void;
@ -73,12 +73,12 @@ export function useTutorial(): UseTutorialReturn {
const [tutorialKey, setTutorialKey] = useState<string | null>(null); const [tutorialKey, setTutorialKey] = useState<string | null>(null);
const onCompleteRef = React.useRef<(() => void) | undefined>(undefined); const onCompleteRef = React.useRef<(() => void) | undefined>(undefined);
const startTutorial = useCallback((key: string, onComplete?: () => void) => { const startTutorial = useCallback((key: string, onComplete?: () => void, forceFromStart?: boolean) => {
const step = tutorialSteps.find(s => s.key === key); const step = tutorialSteps.find(s => s.key === key);
if (!step || step.hints.length === 0) return; if (!step || step.hints.length === 0) return;
// 다른 인스턴스에서 활성화된 튜토리얼이 있으면 skip 처리 // 다른 인스턴스에서 활성화된 튜토리얼이 있으면 skip 처리
globalSkip?.(); globalSkip?.();
const savedIndex = loadProgress(key); const savedIndex = forceFromStart ? 0 : loadProgress(key);
const resumeIndex = savedIndex < step.hints.length ? savedIndex : 0; const resumeIndex = savedIndex < step.hints.length ? savedIndex : 0;
onCompleteRef.current = onComplete; onCompleteRef.current = onComplete;
setHints(step.hints); setHints(step.hints);
@ -132,7 +132,8 @@ export function useTutorial(): UseTutorialReturn {
setIsRestartPopupVisible(false); setIsRestartPopupVisible(false);
if (pendingRestartKey) { if (pendingRestartKey) {
localStorage.removeItem(SEEN_KEY); localStorage.removeItem(SEEN_KEY);
startTutorial(pendingRestartKey); localStorage.removeItem(PROGRESS_KEY);
startTutorial(pendingRestartKey, undefined, true);
} }
setPendingRestartKey(null); setPendingRestartKey(null);
}, [pendingRestartKey, startTutorial]); }, [pendingRestartKey, startTutorial]);

View File

@ -22,8 +22,10 @@
"prev": "Back", "prev": "Back",
"finish": "Done", "finish": "Done",
"landing": { "landing": {
"dropdown": { "title": "Choose Search Type", "desc": "Select URL or business name as your preferred search method." }, "intro": { "title": "Welcome to ADO2 Tutorial", "desc": "We'll guide you through ADO2 step by step.\nFirst, please select your search method." },
"field": { "title": "Enter URL or Business Name", "desc": "Search for a place on Naver Maps, click Share, paste the URL — or type a business name and select it." }, "dropdown": { "title": "Choose Search Type", "desc": "Click the dropdown and select URL or business name as your preferred search method." },
"url": { "title": "Enter Naver Place URL", "desc": "Search for a place on Naver Maps, click Share, and paste the URL here." },
"name": { "title": "Enter Business Name", "desc": "Type a business name and the autocomplete list will appear.\nChoose your business from the list." },
"button": { "title": "Start Brand Analysis", "desc": "Click to let AI analyze your brand." } "button": { "title": "Start Brand Analysis", "desc": "Click to let AI analyze your brand." }
}, },
"analysis": { "analysis": {

View File

@ -22,8 +22,10 @@
"prev": "이전", "prev": "이전",
"finish": "완료", "finish": "완료",
"landing": { "landing": {
"dropdown": { "title": "검색 방식 선택", "desc": "URL 또는 업체명 중 원하는 방식을 선택하세요." }, "intro": { "title": "ADO2 튜토리얼 시작", "desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요.\n먼저 검색 방식을 선택해 주세요." },
"field": { "title": "URL 또는 업체명 입력", "desc": "네이버 지도에서 장소를 검색하고 공유를 클릭하여 나온 URL을 붙여넣거나 업체명을 입력하고 선택하세요." }, "dropdown": { "title": "검색 방식 선택", "desc": "드롭다운을 클릭하여 URL 또는 업체명 중 원하는 방식을 선택하세요." },
"url": { "title": "네이버 Place URL 입력", "desc": "네이버 지도에서 장소를 검색하고 공유를 클릭하여 나온 URL을 붙여넣으세요." },
"name": { "title": "업체명 입력", "desc": "업체명을 입력하면 자동완성 목록이 나타나요.\n목록에서 원하는 업체를 선택하세요." },
"button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." } "button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." }
}, },
"analysis": { "analysis": {
@ -34,33 +36,33 @@
"generate": { "title": "콘텐츠 생성", "desc": "분석 결과를 바탕으로 영상을 만들어 보세요.\n클릭하면 카카오 로그인으로 이동합니다." } "generate": { "title": "콘텐츠 생성", "desc": "분석 결과를 바탕으로 영상을 만들어 보세요.\n클릭하면 카카오 로그인으로 이동합니다." }
}, },
"asset": { "asset": {
"image": { "title": "이미지 목록", "desc": "네이버 Place에서 사진이에요. 더보기를 누르면 나머지 사진도 볼 수 있고 X를 눌러 삭제 할 수 있어요." }, "image": { "title": "이미지 목록", "desc": "네이버 Place에서 가져 온 사진이에요. \n더보기를 누르면 나머지 사진도 볼 수 있고 X를 눌러 삭제 할 수 있어요." },
"upload": { "title": "이미지 추가", "desc": "이미지를 자유롭게 추가 할 수 있어요." }, "upload": { "title": "이미지 추가", "desc": "이미지를 자유롭게 추가 할 수 있어요." },
"ratio": { "title": "영상 비율 선택", "desc": "생성 할 영상의 비율을 선택하세요." }, "ratio": { "title": "영상 비율 선택", "desc": "생성 할 영상의 비율을 선택하세요." },
"next": { "title": "다음 단계로", "desc": "설정이 완료되면 다음 단계로 진행하세요." } "next": { "title": "다음 단계로", "desc": "설정이 완료되면 다음 단계로 진행하세요." }
}, },
"sound": { "sound": {
"genre": { "title": "장르 선택", "desc": "영상에 어울리는 음악 장르를 선택하세요." }, "genre": { "title": "장르 선택", "desc": "영상에 어울리는 음악 장르를 선택하세요." },
"language": { "title": "언어 선택", "desc": "사운드의 언어를 선택하세요. 다음을 눌러주세요." }, "language": { "title": "언어 선택", "desc": "사운드의 언어를 선택할 수 있어요. \n이미 선택된 한국어로 진행해볼까요?" },
"generate": { "title": "사운드 생성", "desc": "AI가 선택한 장르와 언어로 가사와 음악을 생성해요." }, "generate": { "title": "사운드 생성", "desc": "AI가 선택한 장르와 언어로 음악을 생성해요." },
"lyrics": { "title": "생성된 가사", "desc": "AI가 음악에 맞는 가사를 만들었어요.\n생성된 가사를 확인하세요." }, "lyrics": { "title": "가사 생성 완료", "desc": "AI가 선택한 언어로 가사를 만들었어요.\n생성된 가사를 확인하세요." },
"audioPlayer": { "title": "음악 미리 듣기", "desc": "음악 생성이 완료되었어요.\n재생 버튼을 눌러 생성된 음악을 미리 들어보세요." }, "audioPlayer": { "title": "음악 미리 듣기", "desc": "음악 생성이 완료되었어요.\n재생 버튼을 눌러 생성된 음악을 미리 들어보세요." },
"video": { "title": "영상 생성", "desc": "버튼을 클릭해서 영상 생성을 시작하세요." } "video": { "title": "영상 생성", "desc": "버튼을 클릭해서 영상 생성을 시작하세요." }
}, },
"myInfo": { "myInfo": {
"connect": { "title": "소셜 연", "desc": "영상을 업로드하려면 소셜 계정을 연동해야 해요." }, "connect": { "title": "소셜 연", "desc": "영상을 업로드하려면 소셜 계정을 연동해야 해요." },
"button": { "title": "연하기", "desc": "원하는 소셜미디어 버튼을 클릭해서 연동하세요." }, "button": { "title": "연하기", "desc": "원하는 소셜미디어 버튼을 클릭해서 연동하세요. \n(Instaram연결은 개발 중입니다.)" },
"connected": { "title": "연동된 계정", "desc": "연동된 소셜 계정 목록이에요. 연동 후 여기서 확인할 수 있어요." }, "connected": { "title": "연결 계정", "desc": "연결된 소셜 계정 목록이에요. \n연결 후 여기서 확인할 수 있어요." },
"ado2": { "title": "내 콘텐츠 확인", "desc": "연동 후 내 콘텐츠 메뉴에서 생성된 영상을 확인하고 업로드할 수 있어요. 클릭해서 이동하세요." } "ado2": { "title": "ADO2 콘텐츠", "desc": "이제 생성된 영상을 업로드할 수 있어요. 클릭해서 이동하세요." }
}, },
"ado2": { "ado2": {
"list": { "title": "생성된 영상 목록", "desc": "ADO2에서 만든 영상들을 여기서 확인할 수 있어요." }, "list": { "title": "생성된 영상 목록", "desc": "ADO2에서 만든 영상들을 확인할 수 있어요." },
"upload": { "title": "소셜 업로드", "desc": "선택해서 소셜미디어에 업로드하세요." } "upload": { "title": "소셜 업로드", "desc": "선택해서 소셜미디어에 업로드하세요." }
}, },
"completion": { "completion": {
"contentInfo": { "title": "콘텐츠 정보", "desc": "생성된 콘텐츠의 제목, 장르, 규격, 가사 정보를 확인하세요." }, "contentInfo": { "title": "콘텐츠 정보", "desc": "생성된 콘텐츠의 제목, 장르, 규격, 가사 정보를 확인하세요." },
"generating": { "title": "영상 제작 중", "desc": "AI가 영상을 만들고 있어요. 잠시만 기다려 주세요." }, "generating": { "title": "영상 제작 중", "desc": "AI가 영상을 만들고 있어요. 잠시만 기다려 주세요." },
"completion": { "title": "영상 완성!", "desc": "영상 제작이 완료되었어요. 영상을 확인해 볼까요?" }, "completion": { "title": "영상 완성!", "desc": "영상 제작이 완료되었어요. \n영상을 확인해 볼까요?" },
"myInfo": { "title": "소셜 계정 연동", "desc": "영상을 유튜브에 업로드하려면 내 정보에서 소셜 계정을 연동해야 해요. 클릭해서 이동하세요." } "myInfo": { "title": "소셜 계정 연동", "desc": "영상을 유튜브에 업로드하려면 내 정보에서 소셜 계정을 연동해야 해요. 클릭해서 이동하세요." }
}, },
"upload": { "upload": {
@ -79,7 +81,7 @@
"panel": { "title": "콘텐츠 목록", "desc": "자세한 콘텐츠 스케줄을 확인 할수 있어요." } "panel": { "title": "콘텐츠 목록", "desc": "자세한 콘텐츠 스케줄을 확인 할수 있어요." }
}, },
"feedback": { "feedback": {
"complete": { "title": "튜토리얼 완료 🎉", "desc": "브랜드 분석부터 유튜브 업로드까지 모든 과정을 완료했어요. 이제 직접 시작해 보세요!" }, "complete": { "title": "튜토리얼 완료 🎉", "desc": "유튜브 업로드까지 모든 과정을 완료했어요. \n튜토리얼을 다시보고 싶다면 우측 상단의 버튼을 눌러주세요." },
"title": "고객의견", "title": "고객의견",
"desc": "서비스 이용 중 불편한 점이나 개선 의견을 보내주세요." "desc": "서비스 이용 중 불편한 점이나 개선 의견을 보내주세요."
}, },

View File

@ -430,6 +430,13 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
mId={analysisData?.m_id ?? 0} mId={analysisData?.m_id ?? 0}
videoGenerationStatus={videoGenerationStatus} videoGenerationStatus={videoGenerationStatus}
videoGenerationProgress={videoGenerationProgress} videoGenerationProgress={videoGenerationProgress}
onStatusChange={(status: string) => {
if (status === 'generating_song' && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND_LYRICS)) {
setTimeout(() => tutorial.startTutorial(TUTORIAL_KEYS.SOUND_LYRICS), 400);
} else if (status === 'complete' && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND_AUDIO)) {
setTimeout(() => tutorial.startTutorial(TUTORIAL_KEYS.SOUND_AUDIO), 400);
}
}}
/> />
); );
case 3: case 3:

View File

@ -3,9 +3,6 @@ import React, { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { generateLyric, waitForLyricComplete, generateSong, waitForSongComplete } from '../../utils/api'; import { generateLyric, waitForLyricComplete, generateSong, waitForSongComplete } from '../../utils/api';
import { LANGUAGE_MAP } from '../../types/api'; import { LANGUAGE_MAP } from '../../types/api';
import { useTutorial } from '../../components/Tutorial/useTutorial';
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
interface BusinessInfo { interface BusinessInfo {
customer_name: string; customer_name: string;
@ -19,6 +16,7 @@ interface SoundStudioContentProps {
businessInfo?: BusinessInfo; businessInfo?: BusinessInfo;
imageTaskId: string | null; imageTaskId: string | null;
mId: number; mId: number;
onStatusChange?: (status: string) => void;
videoGenerationStatus?: 'idle' | 'generating' | 'complete' | 'error'; videoGenerationStatus?: 'idle' | 'generating' | 'complete' | 'error';
videoGenerationProgress?: number; videoGenerationProgress?: number;
} }
@ -42,11 +40,11 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
businessInfo, businessInfo,
imageTaskId, imageTaskId,
mId, mId,
onStatusChange,
videoGenerationStatus = 'idle', videoGenerationStatus = 'idle',
videoGenerationProgress = 0 videoGenerationProgress = 0
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const tutorial = useTutorial();
const [selectedType, setSelectedType] = useState('보컬'); const [selectedType, setSelectedType] = useState('보컬');
const [selectedLang, setSelectedLang] = useState('한국어'); const [selectedLang, setSelectedLang] = useState('한국어');
const [selectedGenre, setSelectedGenre] = useState('자동 선택'); const [selectedGenre, setSelectedGenre] = useState('자동 선택');
@ -393,24 +391,9 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
const isGenerating = status === 'generating_lyric' || status === 'generating_song' || status === 'polling'; const isGenerating = status === 'generating_lyric' || status === 'generating_song' || status === 'polling';
// 가사 생성 완료 시 가사 튜토리얼 트리거 // status 변경 시 부모에 알림
useEffect(() => { useEffect(() => {
if (status === 'generating_song' && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND_LYRICS)) { onStatusChange?.(status);
const timer = setTimeout(() => {
tutorial.startTutorial(TUTORIAL_KEYS.SOUND_LYRICS);
}, 400);
return () => clearTimeout(timer);
}
}, [status]);
// 음악 생성 완료 시 오디오 플레이어 튜토리얼 트리거
useEffect(() => {
if (status === 'complete' && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND_AUDIO)) {
const timer = setTimeout(() => {
tutorial.startTutorial(TUTORIAL_KEYS.SOUND_AUDIO);
}, 400);
return () => clearTimeout(timer);
}
}, [status]); }, [status]);
return ( return (
@ -669,15 +652,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
</div> </div>
</main> </main>
{tutorial.isActive && (
<TutorialOverlay
hints={tutorial.hints}
currentIndex={tutorial.currentHintIndex}
onNext={tutorial.nextHint}
onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial}
/>
)}
</> </>
); );
}; };

View File

@ -97,9 +97,10 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
const debounceRef = useRef<NodeJS.Timeout | null>(null); const debounceRef = useRef<NodeJS.Timeout | null>(null);
const tutorial = useTutorial(); const tutorial = useTutorial();
// 첫 방문 시 랜딩 튜토리얼 시작 // 첫 방문 시 랜딩 튜토리얼 시작 (URL/업체명 분기 튜토리얼도 아직 안 본 경우)
useEffect(() => { useEffect(() => {
if (!tutorial.hasSeen(TUTORIAL_KEYS.LANDING)) { const neitherBranchSeen = !tutorial.hasSeen(TUTORIAL_KEYS.LANDING_URL) && !tutorial.hasSeen(TUTORIAL_KEYS.LANDING_NAME);
if (!tutorial.hasSeen(TUTORIAL_KEYS.LANDING) && neitherBranchSeen) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
tutorial.startTutorial(TUTORIAL_KEYS.LANDING); tutorial.startTutorial(TUTORIAL_KEYS.LANDING);
}, 800); }, 800);
@ -155,6 +156,10 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
setShowAutocomplete(false); setShowAutocomplete(false);
setAutocompleteResults([]); setAutocompleteResults([]);
setHighlightedIndex(-1); setHighlightedIndex(-1);
// LANDING_NAME 튜토리얼 진행 중이면 다음 힌트로 이동
if (tutorial.isActive && tutorial.tutorialKey === TUTORIAL_KEYS.LANDING_NAME) {
tutorial.nextHint();
}
}; };
// 키보드 네비게이션 핸들러 // 키보드 네비게이션 핸들러
@ -376,6 +381,11 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
onClick={() => { onClick={() => {
setSearchType(option.value); setSearchType(option.value);
setIsDropdownOpen(false); setIsDropdownOpen(false);
if (option.value === 'url' && !tutorial.hasSeen(TUTORIAL_KEYS.LANDING_URL)) {
setTimeout(() => tutorial.startTutorial(TUTORIAL_KEYS.LANDING_URL, undefined, true), 300);
} else if (option.value === 'name' && !tutorial.hasSeen(TUTORIAL_KEYS.LANDING_NAME)) {
setTimeout(() => tutorial.startTutorial(TUTORIAL_KEYS.LANDING_NAME, undefined, true), 300);
}
}} }}
> >
{option.label} {option.label}