튜토리얼 수정
parent
8734d3388d
commit
cef0919879
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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]);
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": "서비스 이용 중 불편한 점이나 개선 의견을 보내주세요."
|
||||
},
|
||||
|
|
|
|||
|
|
@ -430,6 +430,13 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||
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:
|
||||
|
|
|
|||
|
|
@ -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<SoundStudioContentProps> = ({
|
|||
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<SoundStudioContentProps> = ({
|
|||
|
||||
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<SoundStudioContentProps> = ({
|
|||
</div>
|
||||
</main>
|
||||
|
||||
{tutorial.isActive && (
|
||||
<TutorialOverlay
|
||||
hints={tutorial.hints}
|
||||
currentIndex={tutorial.currentHintIndex}
|
||||
onNext={tutorial.nextHint}
|
||||
onPrev={tutorial.prevHint}
|
||||
onSkip={tutorial.skipTutorial}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -97,9 +97,10 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
|||
const debounceRef = useRef<NodeJS.Timeout | null>(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<HeroSectionProps> = ({ 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<HeroSectionProps> = ({ 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}
|
||||
|
|
|
|||
Loading…
Reference in New Issue