diff --git a/src/components/Tutorial/tutorialSteps.ts b/src/components/Tutorial/tutorialSteps.ts index 745a36f..c453bb0 100644 --- a/src/components/Tutorial/tutorialSteps.ts +++ b/src/components/Tutorial/tutorialSteps.ts @@ -16,6 +16,8 @@ export interface TutorialStepDef { export const TUTORIAL_KEYS = { LANDING: 'landing', + LANDING_URL: 'landingUrl', + LANDING_NAME: 'landingName', ANALYSIS: 'analysis', ASSET: 'asset', SOUND: 'sound', @@ -38,17 +40,47 @@ export const tutorialSteps: TutorialStepDef[] = [ hints: [ { 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', descriptionKey: 'tutorial.landing.dropdown.desc', position: 'top', - clickToAdvance: false, - spotlightPaddingOverride: { top: 10, right: 15, bottom: 100, left: 0 }, + clickToAdvance: true, }, + ], + }, + { + key: TUTORIAL_KEYS.LANDING_URL, + hints: [ { targetSelector: '.hero-input-wrapper', - titleKey: 'tutorial.landing.field.title', - descriptionKey: 'tutorial.landing.field.desc', - position: 'right', + titleKey: 'tutorial.landing.url.title', + descriptionKey: 'tutorial.landing.url.desc', + 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, spotlightPaddingOverride: { top: 0, right: 0, bottom: 290, left: -105 }, }, @@ -149,7 +181,6 @@ export const tutorialSteps: TutorialStepDef[] = [ descriptionKey: 'tutorial.sound.language.desc', position: 'top', clickToAdvance: false, - spotlightPaddingOverride: { bottom: 230 }, }, { targetSelector: '.btn-generate-sound', diff --git a/src/components/Tutorial/useTutorial.ts b/src/components/Tutorial/useTutorial.ts index b496e26..55131f4 100644 --- a/src/components/Tutorial/useTutorial.ts +++ b/src/components/Tutorial/useTutorial.ts @@ -54,7 +54,7 @@ interface UseTutorialReturn { currentHintIndex: number; hints: TutorialHint[]; tutorialKey: string | null; - startTutorial: (key: string, onComplete?: () => void) => void; + startTutorial: (key: string, onComplete?: () => void, forceFromStart?: boolean) => void; nextHint: () => void; prevHint: () => void; skipTutorial: () => void; @@ -73,12 +73,12 @@ export function useTutorial(): UseTutorialReturn { const [tutorialKey, setTutorialKey] = useState(null); 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); if (!step || step.hints.length === 0) return; // 다른 인스턴스에서 활성화된 튜토리얼이 있으면 skip 처리 globalSkip?.(); - const savedIndex = loadProgress(key); + const savedIndex = forceFromStart ? 0 : loadProgress(key); const resumeIndex = savedIndex < step.hints.length ? savedIndex : 0; onCompleteRef.current = onComplete; setHints(step.hints); @@ -132,7 +132,8 @@ export function useTutorial(): UseTutorialReturn { setIsRestartPopupVisible(false); if (pendingRestartKey) { localStorage.removeItem(SEEN_KEY); - startTutorial(pendingRestartKey); + localStorage.removeItem(PROGRESS_KEY); + startTutorial(pendingRestartKey, undefined, true); } setPendingRestartKey(null); }, [pendingRestartKey, startTutorial]); diff --git a/src/locales/en.json b/src/locales/en.json index 2c0386f..8109471 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -22,8 +22,10 @@ "prev": "Back", "finish": "Done", "landing": { - "dropdown": { "title": "Choose Search Type", "desc": "Select URL or business name as your preferred 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." }, + "intro": { "title": "Welcome to ADO2 Tutorial", "desc": "We'll guide you through ADO2 step by step.\nFirst, please select your search method." }, + "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." } }, "analysis": { diff --git a/src/locales/ko.json b/src/locales/ko.json index 5268b32..0f607c7 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -22,8 +22,10 @@ "prev": "이전", "finish": "완료", "landing": { - "dropdown": { "title": "검색 방식 선택", "desc": "URL 또는 업체명 중 원하는 방식을 선택하세요." }, - "field": { "title": "URL 또는 업체명 입력", "desc": "네이버 지도에서 장소를 검색하고 공유를 클릭하여 나온 URL을 붙여넣거나 업체명을 입력하고 선택하세요." }, + "intro": { "title": "ADO2 튜토리얼 시작", "desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요.\n먼저 검색 방식을 선택해 주세요." }, + "dropdown": { "title": "검색 방식 선택", "desc": "드롭다운을 클릭하여 URL 또는 업체명 중 원하는 방식을 선택하세요." }, + "url": { "title": "네이버 Place URL 입력", "desc": "네이버 지도에서 장소를 검색하고 공유를 클릭하여 나온 URL을 붙여넣으세요." }, + "name": { "title": "업체명 입력", "desc": "업체명을 입력하면 자동완성 목록이 나타나요.\n목록에서 원하는 업체를 선택하세요." }, "button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." } }, "analysis": { @@ -34,33 +36,33 @@ "generate": { "title": "콘텐츠 생성", "desc": "분석 결과를 바탕으로 영상을 만들어 보세요.\n클릭하면 카카오 로그인으로 이동합니다." } }, "asset": { - "image": { "title": "이미지 목록", "desc": "네이버 Place에서 사진이에요. 더보기를 누르면 나머지 사진도 볼 수 있고 X를 눌러 삭제 할 수 있어요." }, + "image": { "title": "이미지 목록", "desc": "네이버 Place에서 가져 온 사진이에요. \n더보기를 누르면 나머지 사진도 볼 수 있고 X를 눌러 삭제 할 수 있어요." }, "upload": { "title": "이미지 추가", "desc": "이미지를 자유롭게 추가 할 수 있어요." }, "ratio": { "title": "영상 비율 선택", "desc": "생성 할 영상의 비율을 선택하세요." }, "next": { "title": "다음 단계로", "desc": "설정이 완료되면 다음 단계로 진행하세요." } }, "sound": { "genre": { "title": "장르 선택", "desc": "영상에 어울리는 음악 장르를 선택하세요." }, - "language": { "title": "언어 선택", "desc": "사운드의 언어를 선택하세요. 다음을 눌러주세요." }, - "generate": { "title": "사운드 생성", "desc": "AI가 선택한 장르와 언어로 가사와 음악을 생성해요." }, - "lyrics": { "title": "생성된 가사", "desc": "AI가 음악에 맞는 가사를 만들었어요.\n생성된 가사를 확인하세요." }, + "language": { "title": "언어 선택", "desc": "사운드의 언어를 선택할 수 있어요. \n이미 선택된 한국어로 진행해볼까요?" }, + "generate": { "title": "사운드 생성", "desc": "AI가 선택한 장르와 언어로 음악을 생성해요." }, + "lyrics": { "title": "가사 생성 완료", "desc": "AI가 선택한 언어로 가사를 만들었어요.\n생성된 가사를 확인하세요." }, "audioPlayer": { "title": "음악 미리 듣기", "desc": "음악 생성이 완료되었어요.\n재생 버튼을 눌러 생성된 음악을 미리 들어보세요." }, "video": { "title": "영상 생성", "desc": "버튼을 클릭해서 영상 생성을 시작하세요." } }, "myInfo": { - "connect": { "title": "소셜 연동", "desc": "영상을 업로드하려면 소셜 계정을 연동해야 해요." }, - "button": { "title": "연동하기", "desc": "원하는 소셜미디어 버튼을 클릭해서 연동하세요." }, - "connected": { "title": "연동된 계정", "desc": "연동된 소셜 계정 목록이에요. 연동 후 여기서 확인할 수 있어요." }, - "ado2": { "title": "내 콘텐츠 확인", "desc": "연동 후 내 콘텐츠 메뉴에서 생성된 영상을 확인하고 업로드할 수 있어요. 클릭해서 이동하세요." } + "connect": { "title": "소셜 연결", "desc": "영상을 업로드하려면 소셜 계정을 연동해야 해요." }, + "button": { "title": "연결하기", "desc": "원하는 소셜미디어 버튼을 클릭해서 연동하세요. \n(Instaram연결은 개발 중입니다.)" }, + "connected": { "title": "연결 계정", "desc": "연결된 소셜 계정 목록이에요. \n연결 후 여기서 확인할 수 있어요." }, + "ado2": { "title": "ADO2 콘텐츠", "desc": "이제 생성된 영상을 업로드할 수 있어요. 클릭해서 이동하세요." } }, "ado2": { - "list": { "title": "생성된 영상 목록", "desc": "ADO2에서 만든 영상들을 여기서 확인할 수 있어요." }, + "list": { "title": "생성된 영상 목록", "desc": "ADO2에서 만든 영상들을 확인할 수 있어요." }, "upload": { "title": "소셜 업로드", "desc": "선택해서 소셜미디어에 업로드하세요." } }, "completion": { "contentInfo": { "title": "콘텐츠 정보", "desc": "생성된 콘텐츠의 제목, 장르, 규격, 가사 정보를 확인하세요." }, "generating": { "title": "영상 제작 중", "desc": "AI가 영상을 만들고 있어요. 잠시만 기다려 주세요." }, - "completion": { "title": "영상 완성!", "desc": "영상 제작이 완료되었어요. 영상을 확인해 볼까요?" }, + "completion": { "title": "영상 완성!", "desc": "영상 제작이 완료되었어요. \n영상을 확인해 볼까요?" }, "myInfo": { "title": "소셜 계정 연동", "desc": "영상을 유튜브에 업로드하려면 내 정보에서 소셜 계정을 연동해야 해요. 클릭해서 이동하세요." } }, "upload": { @@ -79,7 +81,7 @@ "panel": { "title": "콘텐츠 목록", "desc": "자세한 콘텐츠 스케줄을 확인 할수 있어요." } }, "feedback": { - "complete": { "title": "튜토리얼 완료 🎉", "desc": "브랜드 분석부터 유튜브 업로드까지 모든 과정을 완료했어요. 이제 직접 시작해 보세요!" }, + "complete": { "title": "튜토리얼 완료 🎉", "desc": "유튜브 업로드까지 모든 과정을 완료했어요. \n튜토리얼을 다시보고 싶다면 우측 상단의 버튼을 눌러주세요." }, "title": "고객의견", "desc": "서비스 이용 중 불편한 점이나 개선 의견을 보내주세요." }, diff --git a/src/pages/Dashboard/GenerationFlow.tsx b/src/pages/Dashboard/GenerationFlow.tsx index 72a8269..f750bca 100755 --- a/src/pages/Dashboard/GenerationFlow.tsx +++ b/src/pages/Dashboard/GenerationFlow.tsx @@ -430,6 +430,13 @@ const GenerationFlow: React.FC = ({ mId={analysisData?.m_id ?? 0} videoGenerationStatus={videoGenerationStatus} 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: diff --git a/src/pages/Dashboard/SoundStudioContent.tsx b/src/pages/Dashboard/SoundStudioContent.tsx index 451ec89..f79d126 100755 --- a/src/pages/Dashboard/SoundStudioContent.tsx +++ b/src/pages/Dashboard/SoundStudioContent.tsx @@ -3,9 +3,6 @@ import React, { useState, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { generateLyric, waitForLyricComplete, generateSong, waitForSongComplete } from '../../utils/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 { customer_name: string; @@ -19,6 +16,7 @@ interface SoundStudioContentProps { businessInfo?: BusinessInfo; imageTaskId: string | null; mId: number; + onStatusChange?: (status: string) => void; videoGenerationStatus?: 'idle' | 'generating' | 'complete' | 'error'; videoGenerationProgress?: number; } @@ -42,11 +40,11 @@ const SoundStudioContent: React.FC = ({ businessInfo, imageTaskId, mId, + onStatusChange, videoGenerationStatus = 'idle', videoGenerationProgress = 0 }) => { const { t } = useTranslation(); - const tutorial = useTutorial(); const [selectedType, setSelectedType] = useState('보컬'); const [selectedLang, setSelectedLang] = useState('한국어'); const [selectedGenre, setSelectedGenre] = useState('자동 선택'); @@ -393,24 +391,9 @@ const SoundStudioContent: React.FC = ({ const isGenerating = status === 'generating_lyric' || status === 'generating_song' || status === 'polling'; - // 가사 생성 완료 시 가사 튜토리얼 트리거 + // status 변경 시 부모에 알림 useEffect(() => { - if (status === 'generating_song' && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND_LYRICS)) { - 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); - } + onStatusChange?.(status); }, [status]); return ( @@ -669,15 +652,6 @@ const SoundStudioContent: React.FC = ({ - {tutorial.isActive && ( - - )} ); }; diff --git a/src/pages/Landing/HeroSection.tsx b/src/pages/Landing/HeroSection.tsx index ac53236..4c5941a 100755 --- a/src/pages/Landing/HeroSection.tsx +++ b/src/pages/Landing/HeroSection.tsx @@ -97,9 +97,10 @@ const HeroSection: React.FC = ({ onAnalyze, onAutocomplete, on const debounceRef = useRef(null); const tutorial = useTutorial(); - // 첫 방문 시 랜딩 튜토리얼 시작 + // 첫 방문 시 랜딩 튜토리얼 시작 (URL/업체명 분기 튜토리얼도 아직 안 본 경우) 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(() => { tutorial.startTutorial(TUTORIAL_KEYS.LANDING); }, 800); @@ -155,6 +156,10 @@ const HeroSection: React.FC = ({ onAnalyze, onAutocomplete, on setShowAutocomplete(false); setAutocompleteResults([]); setHighlightedIndex(-1); + // LANDING_NAME 튜토리얼 진행 중이면 다음 힌트로 이동 + if (tutorial.isActive && tutorial.tutorialKey === TUTORIAL_KEYS.LANDING_NAME) { + tutorial.nextHint(); + } }; // 키보드 네비게이션 핸들러 @@ -376,6 +381,11 @@ const HeroSection: React.FC = ({ onAnalyze, onAutocomplete, on onClick={() => { setSearchType(option.value); 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}