diff --git a/index.css b/index.css index 2ef0045..62e6e0b 100644 --- a/index.css +++ b/index.css @@ -7798,7 +7798,7 @@ top: calc(100% + 0.5rem); left: 0; right: 0; - max-height: 240px; + max-height: 220px; background-color: #002224; border: 1px solid #046266; border-radius: 8px; @@ -10568,10 +10568,25 @@ inset: 0; width: 100%; height: 100%; - pointer-events: all; + pointer-events: none; cursor: default; } +.tutorial-overlay-blocker { + position: fixed; + background: rgba(0, 0, 0, 0.72); + pointer-events: all; +} + +.tutorial-spotlight-ring { + position: fixed; + border: 2px solid rgba(166, 255, 234, 0.85); + border-radius: 12px; + box-shadow: 0 0 0 1px rgba(166, 255, 234, 0.2); + pointer-events: none; + z-index: 10001; +} + .tutorial-tooltip { position: fixed; width: 300px; @@ -10596,6 +10611,7 @@ color: rgba(255, 255, 255, 0.75); margin: 0 0 14px; line-height: 1.5; + white-space: pre-line; } .tutorial-tooltip-footer { diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index bdbf1a6..9cab585 100755 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -140,7 +140,13 @@ const Sidebar: React.FC = ({ activeItem, onNavigate, onHome, userI {menuItems.map(item => ( = ({ const [videoMeta, setVideoMeta] = useState<{ width: number; height: number; duration: number } | null>(null); const channelDropdownRef = useRef(null); const privacyDropdownRef = useRef(null); + const hasBeenOpenedRef = useRef(false); // Upload progress modal state const [showUploadProgress, setShowUploadProgress] = useState(false); @@ -175,15 +176,31 @@ const SocialPostingModal: React.FC = ({ return () => { document.body.style.overflow = ''; }; }, [isOpen]); - // 모달 최초 오픈 시 튜토리얼 트리거 + // 모달 오픈/닫힘 시 튜토리얼 트리거 useEffect(() => { - if (isOpen && !tutorial.hasSeen(TUTORIAL_KEYS.UPLOAD_MODAL)) { + if (isOpen) { + hasBeenOpenedRef.current = true; + if (!tutorial.hasSeen(TUTORIAL_KEYS.UPLOAD_MODAL)) { + const timer = setTimeout(() => { + tutorial.startTutorial(TUTORIAL_KEYS.UPLOAD_MODAL); + }, 400); + return () => clearTimeout(timer); + } + } else if (hasBeenOpenedRef.current && !tutorial.hasSeen(TUTORIAL_KEYS.FEEDBACK)) { + hasBeenOpenedRef.current = false; + tutorial.startTutorial(TUTORIAL_KEYS.FEEDBACK); + } + }, [isOpen]); + + // SEO 생성 완료 시 UPLOAD_FORM 튜토리얼 트리거 + useEffect(() => { + if (!isLoadingAutoDescription && isOpen && !tutorial.hasSeen(TUTORIAL_KEYS.UPLOAD_FORM)) { const timer = setTimeout(() => { - tutorial.startTutorial(TUTORIAL_KEYS.UPLOAD_MODAL); + tutorial.startTutorial(TUTORIAL_KEYS.UPLOAD_FORM); }, 400); return () => clearTimeout(timer); } - }, [isOpen]); + }, [isLoadingAutoDescription]); // 소셜 계정 로드 useEffect(() => { @@ -413,8 +430,20 @@ const SocialPostingModal: React.FC = ({ ); if (!isOpen || !video) { - // Still render upload progress modal even when main modal is closed - return showUploadProgress ? uploadProgressModalElement : null; + return ( + <> + {showUploadProgress && uploadProgressModalElement} + {tutorial.isActive && ( + + )} + + ); } return ( diff --git a/src/components/Tutorial/TutorialOverlay.tsx b/src/components/Tutorial/TutorialOverlay.tsx index 1c9e267..26d89fa 100644 --- a/src/components/Tutorial/TutorialOverlay.tsx +++ b/src/components/Tutorial/TutorialOverlay.tsx @@ -31,6 +31,28 @@ function getTargetRect(selector: string): Rect | null { return { top: r.top, left: r.left, width: r.width, height: r.height }; } +function getSpotlightRect( + rect: Rect, + padding: number, + override?: { top?: number; right?: number; bottom?: number; left?: number } +): Rect { + const pTop = override?.top ?? padding; + const pRight = override?.right ?? padding; + const pBottom = override?.bottom ?? padding; + const pLeft = override?.left ?? padding; + const left = Math.max(0, Math.floor(rect.left - pLeft)); + const top = Math.max(0, Math.floor(rect.top - pTop)); + const right = Math.min(window.innerWidth, Math.ceil(rect.left + rect.width + pRight)); + const bottom = Math.min(window.innerHeight, Math.ceil(rect.top + rect.height + pBottom)); + + return { + top, + left, + width: Math.max(0, right - left), + height: Math.max(0, bottom - top), + }; +} + function calcTooltipPos(rect: Rect, position: TutorialHint['position']): TooltipPos { const tooltipW = 300; const tooltipH = 140; @@ -38,29 +60,23 @@ function calcTooltipPos(rect: Rect, position: TutorialHint['position']): Tooltip switch (position) { case 'bottom': return { - top: rect.top + rect.height + PADDING, - left: Math.min( - Math.max(rect.left + rect.width / 2 - tooltipW / 2, 8), - window.innerWidth - tooltipW - 8 - ), + top: Math.min(rect.top + rect.height + PADDING, window.innerHeight - tooltipH - 8), + left: Math.min(Math.max(rect.left + rect.width / 2 - tooltipW / 2, 8), window.innerWidth - tooltipW - 8), }; case 'top': return { - top: rect.top - tooltipH - PADDING, - left: Math.min( - Math.max(rect.left + rect.width / 2 - tooltipW / 2, 8), - window.innerWidth - tooltipW - 8 - ), + top: Math.max(rect.top - tooltipH - PADDING, 8), + left: Math.min(Math.max(rect.left + rect.width / 2 - tooltipW / 2, 8), window.innerWidth - tooltipW - 8), }; case 'right': return { - top: Math.max(rect.top + rect.height / 2 - tooltipH / 2, 8), - left: rect.left + rect.width + PADDING, + top: Math.min(Math.max(rect.top + rect.height / 2 - tooltipH / 2, 8), window.innerHeight - tooltipH - 8), + left: Math.min(rect.left + rect.width + PADDING, window.innerWidth - tooltipW - 8), }; case 'left': return { - top: Math.max(rect.top + rect.height / 2 - tooltipH / 2, 8), - left: rect.left - tooltipW - PADDING, + top: Math.min(Math.max(rect.top + rect.height / 2 - tooltipH / 2, 8), window.innerHeight - tooltipH - 8), + left: Math.max(rect.left - tooltipW - PADDING, 8), }; } } @@ -86,47 +102,62 @@ const TutorialOverlay: React.FC = ({ useEffect(() => { updateRect(); window.addEventListener('resize', updateRect); - return () => window.removeEventListener('resize', updateRect); + window.addEventListener('scroll', updateRect, true); + return () => { + window.removeEventListener('resize', updateRect); + window.removeEventListener('scroll', updateRect, true); + }; }, [updateRect]); - // 대상 요소 z-index 끌어올리기 + 스크롤 + 클릭 시 onNext 연결 useEffect(() => { if (!hint) return; - const el = document.querySelector(hint.targetSelector); - if (!el) return; + let retryTimer: number | undefined; + let rectTimer: number | undefined; + let cleanupTarget: (() => void) | undefined; - // 스크롤해서 요소가 화면 중앙에 오도록 - el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + const bindToTarget = (el: HTMLElement) => { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + rectTimer = window.setTimeout(updateRect, 200); - // scrollIntoView 완료 후 rect 재계산 - const timer = setTimeout(updateRect, 400); + const shouldClickAdvance = hint.clickToAdvance !== false; + if (shouldClickAdvance) { + el.style.cursor = 'pointer'; + el.addEventListener('click', onNext); + } - const prevZ = el.style.zIndex; - const prevPos = el.style.position; - const prevCursor = el.style.cursor; - el.style.zIndex = '10001'; - el.style.position = el.style.position || 'relative'; - // clickToAdvance가 명시적으로 false가 아닌 경우에만 클릭 리스너 연결 - const shouldClickAdvance = hint.clickToAdvance !== false; - if (shouldClickAdvance) { - el.style.cursor = 'pointer'; - el.addEventListener('click', onNext); + cleanupTarget = () => { + if (shouldClickAdvance) { + el.style.cursor = ''; + el.removeEventListener('click', onNext); + } + }; + }; + + const tryBind = (): boolean => { + const el = document.querySelector(hint.targetSelector) as HTMLElement | null; + if (!el) return false; + bindToTarget(el); + return true; + }; + + if (!tryBind()) { + retryTimer = window.setInterval(() => { + if (tryBind() && retryTimer) { + window.clearInterval(retryTimer); + retryTimer = undefined; + } + }, 80); } return () => { - clearTimeout(timer); - el.style.zIndex = prevZ; - el.style.position = prevPos; - el.style.cursor = prevCursor; - if (shouldClickAdvance) { - el.removeEventListener('click', onNext); - } + if (retryTimer) window.clearInterval(retryTimer); + if (rectTimer) window.clearTimeout(rectTimer); + cleanupTarget?.(); }; }, [hint, updateRect, onNext]); if (!hint) return null; - // 대상 요소가 없으면 화면 중앙에 툴팁 표시 (오버레이는 보이지 않음) const tooltipPos: TooltipPos = targetRect ? calcTooltipPos(targetRect, hint.position) : { @@ -134,57 +165,85 @@ const TutorialOverlay: React.FC = ({ left: window.innerWidth / 2 - 150, }; - // spotlight SVG 경로: 화면 전체 - 대상 요소 구멍 - // 대상 요소가 없으면 오버레이 없음 - const spotlight = targetRect - ? `M0,0 H${window.innerWidth} V${window.innerHeight} H0 Z - M${targetRect.left - PADDING},${targetRect.top - PADDING} - H${targetRect.left + targetRect.width + PADDING} - V${targetRect.top + targetRect.height + PADDING} - H${targetRect.left - PADDING} Z` - : null; + const spotlightPadding = hint.spotlightPadding ?? PADDING; + const spotlightRect = targetRect ? getSpotlightRect(targetRect, spotlightPadding, hint.spotlightPaddingOverride) : null; return (
- {/* 어두운 배경 + 스포트라이트 — 배경 클릭 시 아무 동작 없음 */} - {spotlight && ( - - +
- +
+
+
+
+ + ) : ( +
)} - {/* 툴팁 — 항상 표시 */}
e.stopPropagation()} - > -

{t(hint.titleKey)}

-

{t(hint.descriptionKey)}

+ className="tutorial-tooltip" + style={{ top: tooltipPos.top, left: tooltipPos.left }} + onClick={(e) => e.stopPropagation()} + > +

{t(hint.titleKey)}

+

{t(hint.descriptionKey)}

-
- - {currentIndex + 1} / {hints.length} - -
- + {currentIndex > 0 && ( + - {currentIndex > 0 && ( - - )} - -
+ )} +
+
); }; @@ -200,7 +259,7 @@ export const TutorialRestartPopup: React.FC = ({ onCo const { t } = useTranslation(); return (
-
e.stopPropagation()}> +
e.stopPropagation()}>

{t('tutorial.restart.title')}

{t('tutorial.restart.desc')}

diff --git a/src/components/Tutorial/tutorialSteps.ts b/src/components/Tutorial/tutorialSteps.ts index da29c12..745a36f 100644 --- a/src/components/Tutorial/tutorialSteps.ts +++ b/src/components/Tutorial/tutorialSteps.ts @@ -3,7 +3,10 @@ export interface TutorialHint { titleKey: string; descriptionKey: string; position: 'top' | 'bottom' | 'left' | 'right'; - clickToAdvance?: boolean; // true면 요소 클릭 시 튜토리얼 진행, false면 툴팁 버튼으로만 진행 + clickToAdvance?: boolean; + advanceSelector?: string; + spotlightPadding?: number; + spotlightPaddingOverride?: { top?: number; right?: number; bottom?: number; left?: number }; } export interface TutorialStepDef { @@ -16,11 +19,16 @@ export const TUTORIAL_KEYS = { ANALYSIS: 'analysis', ASSET: 'asset', SOUND: 'sound', + SOUND_LYRICS: 'soundLyrics', + SOUND_AUDIO: 'soundAudio', + GENERATING:'generating', COMPLETION: 'completion', MY_INFO: 'myInfo', ADO2_CONTENTS: 'ado2Contents', UPLOAD_MODAL: 'uploadModal', + UPLOAD_FORM: 'uploadForm', DASHBOARD: 'dashboard', + CONTENT_CALENDAR: 'contentCalendar', FEEDBACK: 'feedback', } as const; @@ -32,19 +40,24 @@ export const tutorialSteps: TutorialStepDef[] = [ targetSelector: '.hero-dropdown-container', titleKey: 'tutorial.landing.dropdown.title', descriptionKey: 'tutorial.landing.dropdown.desc', - position: 'bottom', + position: 'top', + clickToAdvance: false, + spotlightPaddingOverride: { top: 10, right: 15, bottom: 100, left: 0 }, }, { - targetSelector: '.hero-input-container', + targetSelector: '.hero-input-wrapper', titleKey: 'tutorial.landing.field.title', descriptionKey: 'tutorial.landing.field.desc', - position: 'bottom', + position: 'right', + clickToAdvance: false, + spotlightPaddingOverride: { top: 0, right: 0, bottom: 290, left: -105 }, }, { targetSelector: '.hero-button', titleKey: 'tutorial.landing.button.title', descriptionKey: 'tutorial.landing.button.desc', - position: 'top', + position: 'bottom', + clickToAdvance: true, }, ], }, @@ -55,35 +68,35 @@ export const tutorialSteps: TutorialStepDef[] = [ targetSelector: '.bi2-identity-card', titleKey: 'tutorial.analysis.identity.title', descriptionKey: 'tutorial.analysis.identity.desc', - position: 'bottom', + position: 'top', clickToAdvance: false, }, { targetSelector: '.bi2-selling-card', titleKey: 'tutorial.analysis.selling.title', descriptionKey: 'tutorial.analysis.selling.desc', - position: 'bottom', + position: 'top', clickToAdvance: false, }, { targetSelector: '.bi2-persona-grid', titleKey: 'tutorial.analysis.persona.title', descriptionKey: 'tutorial.analysis.persona.desc', - position: 'bottom', + position: 'top', clickToAdvance: false, }, { targetSelector: '.bi2-keyword-tags', titleKey: 'tutorial.analysis.keywords.title', descriptionKey: 'tutorial.analysis.keywords.desc', - position: 'bottom', + position: 'top', clickToAdvance: false, }, { targetSelector: '.bi2-generate-btn', titleKey: 'tutorial.analysis.generate.title', descriptionKey: 'tutorial.analysis.generate.desc', - position: 'top', + position: 'right', clickToAdvance: true, }, ], @@ -91,11 +104,19 @@ export const tutorialSteps: TutorialStepDef[] = [ { key: TUTORIAL_KEYS.ASSET, hints: [ + { + targetSelector: '.asset-column.asset-column-left', + titleKey: 'tutorial.asset.image.title', + descriptionKey: 'tutorial.asset.image.desc', + position: 'left', + clickToAdvance: false, + }, { targetSelector: '.asset-upload-zone', titleKey: 'tutorial.asset.upload.title', descriptionKey: 'tutorial.asset.upload.desc', position: 'left', + clickToAdvance: false, }, { targetSelector: '.asset-ratio-section', @@ -108,6 +129,7 @@ export const tutorialSteps: TutorialStepDef[] = [ titleKey: 'tutorial.asset.next.title', descriptionKey: 'tutorial.asset.next.desc', position: 'top', + clickToAdvance: true, }, ], }, @@ -118,23 +140,48 @@ export const tutorialSteps: TutorialStepDef[] = [ targetSelector: '.genre-grid', titleKey: 'tutorial.sound.genre.title', descriptionKey: 'tutorial.sound.genre.desc', - position: 'right', - clickToAdvance: false, + position: 'top', + clickToAdvance: true, }, { targetSelector: '.language-selector-wrapper', titleKey: 'tutorial.sound.language.title', descriptionKey: 'tutorial.sound.language.desc', - position: 'right', + position: 'top', clickToAdvance: false, + spotlightPaddingOverride: { bottom: 230 }, }, { targetSelector: '.btn-generate-sound', titleKey: 'tutorial.sound.generate.title', descriptionKey: 'tutorial.sound.generate.desc', - position: 'top', + position: 'right', clickToAdvance: true, }, + ], + }, + { + key: TUTORIAL_KEYS.SOUND_LYRICS, + hints: [ + { + targetSelector: '.lyrics-display', + titleKey: 'tutorial.sound.lyrics.title', + descriptionKey: 'tutorial.sound.lyrics.desc', + position: 'left', + clickToAdvance: false, + }, + ], + }, + { + key: TUTORIAL_KEYS.SOUND_AUDIO, + hints: [ + { + targetSelector: '.audio-player', + titleKey: 'tutorial.sound.audioPlayer.title', + descriptionKey: 'tutorial.sound.audioPlayer.desc', + position: 'left', + clickToAdvance: false, + }, { targetSelector: '.btn-video-generate', titleKey: 'tutorial.sound.video.title', @@ -151,36 +198,76 @@ export const tutorialSteps: TutorialStepDef[] = [ targetSelector: '.youtube-connect-section', titleKey: 'tutorial.myInfo.connect.title', descriptionKey: 'tutorial.myInfo.connect.desc', - position: 'bottom', + position: 'top', }, { - targetSelector: '.youtube-connect-button', + targetSelector: '.myinfo-social-btn.connected', titleKey: 'tutorial.myInfo.button.title', descriptionKey: 'tutorial.myInfo.button.desc', position: 'top', }, + { + targetSelector: '.myinfo-connected-accounts', + titleKey: 'tutorial.myInfo.connected.title', + descriptionKey: 'tutorial.myInfo.connected.desc', + position: 'top', + clickToAdvance: false, + }, + { + targetSelector: '#sidebar-ado2-contents', + titleKey: 'tutorial.myInfo.ado2.title', + descriptionKey: 'tutorial.myInfo.ado2.desc', + position: 'right', + clickToAdvance: true, + }, ], }, { key: TUTORIAL_KEYS.ADO2_CONTENTS, hints: [ { - targetSelector: '.ado2-contents-list', + targetSelector: '.ado2-content-card', titleKey: 'tutorial.ado2.list.title', descriptionKey: 'tutorial.ado2.list.desc', - position: 'bottom', + position: 'right', + clickToAdvance: false, }, { - targetSelector: '.ado2-upload-button', + targetSelector: '.content-download-btn', titleKey: 'tutorial.ado2.upload.title', descriptionKey: 'tutorial.ado2.upload.desc', - position: 'top', + position: 'right', + clickToAdvance: true, }, ], }, + { + key: TUTORIAL_KEYS.GENERATING, + hints:[ + { + targetSelector: '.comp2-info-section', + titleKey: 'tutorial.completion.contentInfo.title', + descriptionKey: 'tutorial.completion.contentInfo.desc', + position: 'left', + }, + { + targetSelector: '.comp2-video-section', + titleKey: 'tutorial.completion.generating.title', + descriptionKey: 'tutorial.completion.generating.desc', + position: 'right', + } + ] + }, { key: TUTORIAL_KEYS.COMPLETION, hints: [ + { + targetSelector: '.comp2-video-section', + titleKey: 'tutorial.completion.completion.title', + descriptionKey: 'tutorial.completion.completion.desc', + position: 'right', + clickToAdvance: false, + }, { targetSelector: '#sidebar-my-info', titleKey: 'tutorial.completion.myInfo.title', @@ -194,9 +281,21 @@ export const tutorialSteps: TutorialStepDef[] = [ key: TUTORIAL_KEYS.UPLOAD_MODAL, hints: [ { - targetSelector: '.social-posting-field', - titleKey: 'tutorial.upload.title.title', - descriptionKey: 'tutorial.upload.title.desc', + targetSelector: '.social-posting-content', + titleKey: 'tutorial.upload.seo.title', + descriptionKey: 'tutorial.upload.seo.desc', + position: 'right', + clickToAdvance: false, + }, + ], + }, + { + key: TUTORIAL_KEYS.UPLOAD_FORM, + hints: [ + { + targetSelector: '.social-posting-form', + titleKey: 'tutorial.upload.required.title', + descriptionKey: 'tutorial.upload.required.desc', position: 'left', clickToAdvance: false, }, @@ -220,22 +319,55 @@ export const tutorialSteps: TutorialStepDef[] = [ key: TUTORIAL_KEYS.DASHBOARD, hints: [ { - targetSelector: '.dashboard-metrics-row', + targetSelector: '.stats-grid-8', titleKey: 'tutorial.dashboard.metrics.title', descriptionKey: 'tutorial.dashboard.metrics.desc', position: 'bottom', }, { - targetSelector: '.dashboard-chart-section', + targetSelector: '.yoy-chart-card', titleKey: 'tutorial.dashboard.chart.title', descriptionKey: 'tutorial.dashboard.chart.desc', + position: 'right', + }, + { + targetSelector: '.tutorial-center-anchor', + titleKey: 'tutorial.dashboard.more.title', + descriptionKey: 'tutorial.dashboard.more.desc', + position: 'bottom', + clickToAdvance: false, + }, + ], + }, + { + key: TUTORIAL_KEYS.CONTENT_CALENDAR, + hints: [ + { + targetSelector: '.calendar-grid-area', + titleKey: 'tutorial.contentCalendar.grid.title', + descriptionKey: 'tutorial.contentCalendar.grid.desc', position: 'top', + clickToAdvance: true, + }, + { + targetSelector: '.calendar-side-panel', + titleKey: 'tutorial.contentCalendar.panel.title', + descriptionKey: 'tutorial.contentCalendar.panel.desc', + position: 'left', + clickToAdvance: false, }, ], }, { key: TUTORIAL_KEYS.FEEDBACK, hints: [ + { + targetSelector: '.tutorial-center-anchor', + titleKey: 'tutorial.feedback.complete.title', + descriptionKey: 'tutorial.feedback.complete.desc', + position: 'bottom', + clickToAdvance: false, + }, { targetSelector: '.sidebar-inquiry-btn', titleKey: 'tutorial.feedback.title', diff --git a/src/components/Tutorial/useTutorial.ts b/src/components/Tutorial/useTutorial.ts index 9b92a7a..b496e26 100644 --- a/src/components/Tutorial/useTutorial.ts +++ b/src/components/Tutorial/useTutorial.ts @@ -1,7 +1,11 @@ -import { useState, useCallback } from 'react'; +import React, { useState, useCallback } from 'react'; import { tutorialSteps, TutorialHint } from './tutorialSteps'; -const SEEN_KEY = 'castad_tutorial_seen'; +const SEEN_KEY = 'ado2_tutorial_seen'; +const PROGRESS_KEY = 'ado2_tutorial_progress'; + +// 전역 단일 활성 튜토리얼 관리 — 새 튜토리얼 시작 시 이전 것을 skip 처리 +let globalSkip: (() => void) | null = null; function getSeenKeys(): string[] { try { @@ -16,6 +20,32 @@ function markSeen(key: string) { 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 { @@ -24,7 +54,7 @@ interface UseTutorialReturn { currentHintIndex: number; hints: TutorialHint[]; tutorialKey: string | null; - startTutorial: (key: string) => void; + startTutorial: (key: string, onComplete?: () => void) => void; nextHint: () => void; prevHint: () => void; skipTutorial: () => void; @@ -41,22 +71,41 @@ export function useTutorial(): UseTutorialReturn { const [currentHintIndex, setCurrentHintIndex] = useState(0); const [hints, setHints] = useState([]); const [tutorialKey, setTutorialKey] = useState(null); + const onCompleteRef = React.useRef<(() => void) | undefined>(undefined); - const startTutorial = useCallback((key: string) => { + const startTutorial = useCallback((key: string, onComplete?: () => void) => { const step = tutorialSteps.find(s => s.key === key); if (!step || step.hints.length === 0) return; + // 다른 인스턴스에서 활성화된 튜토리얼이 있으면 skip 처리 + globalSkip?.(); + const savedIndex = loadProgress(key); + const resumeIndex = savedIndex < step.hints.length ? savedIndex : 0; + onCompleteRef.current = onComplete; setHints(step.hints); setTutorialKey(key); - setCurrentHintIndex(0); + 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) return prev + 1; - // 마지막 힌트 완료 → seen 기록 + if (prev < hints.length - 1) { + const next = prev + 1; + if (tutorialKey) saveProgress(tutorialKey, next); + return next; + } + // 마지막 힌트 완료 → seen 기록 + 진행 상태 삭제 setIsActive(false); if (tutorialKey) markSeen(tutorialKey); + onCompleteRef.current?.(); + onCompleteRef.current = undefined; return 0; }); }, [hints.length, tutorialKey]); @@ -65,11 +114,12 @@ export function useTutorial(): UseTutorialReturn { setCurrentHintIndex(prev => Math.max(0, prev - 1)); }, []); - // 건너뛰기: 오버레이만 닫고 seen 기록 안 함 → 다음 방문 시 다시 표시 + // 건너뛰기: 현재 진행 인덱스 저장 후 오버레이 닫기 → 다음 방문 시 이어서 표시 const skipTutorial = useCallback(() => { + if (tutorialKey) saveProgress(tutorialKey, currentHintIndex); setIsActive(false); setCurrentHintIndex(0); - }, []); + }, [tutorialKey, currentHintIndex]); // 튜토리얼 다시 보기: 팝업 표시만 const showRestartPopup = useCallback((key: string) => { diff --git a/src/locales/en.json b/src/locales/en.json index 84330b0..2c0386f 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -21,53 +21,68 @@ "next": "Next", "prev": "Back", "finish": "Done", - "clickToProceed": "Click the highlighted item to continue.", - "clickToFinish": "Click the highlighted item to finish the tutorial.", "landing": { - "dropdown": { "title": "Choose Search Type", "desc": "Search by URL or business name." }, - "field": { "title": "Enter URL or Business Name", "desc": "Paste a Naver Maps share URL or type a business name." }, + "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." }, "button": { "title": "Start Brand Analysis", "desc": "Click to let AI analyze your brand." } }, "analysis": { - "identity": { "title": "Brand Identity", "desc": "Check the core values and market positioning analyzed by AI." }, - "selling": { "title": "Key Selling Points", "desc": "See your brand's strengths ranked by score." }, + "identity": { "title": "Brand Identity", "desc": "Check the core values and market positioning AI analyzed for your brand." }, + "selling": { "title": "Key Selling Points", "desc": "See your brand's strengths." }, "persona": { "title": "Target Customer Types", "desc": "AI analyzed what kind of customers visit this brand." }, "keywords": { "title": "Recommended Keywords", "desc": "Keywords your customers are likely to search for." }, - "generate": { "title": "Generate Content", "desc": "Create video content based on the analysis. Click to proceed to the next step." } + "generate": { "title": "Generate Content", "desc": "Create video content based on the analysis.\nClick to sign in with Kakao and continue." } }, "asset": { - "upload": { "title": "Add Images", "desc": "Upload or remove brand images." }, - "ratio": { "title": "Select Video Ratio", "desc": "Choose the best ratio for YouTube." }, + "image": { "title": "Image List", "desc": "Photos from Naver Place. Tap 'Show more' to see the rest, or X to remove any." }, + "upload": { "title": "Add Images", "desc": "You can freely add more images." }, + "ratio": { "title": "Select Video Ratio", "desc": "Choose the ratio for the video to be generated." }, "next": { "title": "Next Step", "desc": "Proceed to the next step when ready." } }, "sound": { "genre": { "title": "Select Genre", "desc": "Pick a music genre that fits your brand." }, - "language": { "title": "Select Language", "desc": "Choose the language for the sound." }, - "generate": { "title": "Generate Sound", "desc": "AI will create music tailored to your brand." }, - "video": { "title": "Generate Video", "desc": "Create your video after generating the sound." } + "language": { "title": "Select Language", "desc": "Choose the language for the sound. Then click Next." }, + "generate": { "title": "Generate Sound", "desc": "AI will create lyrics and music in your chosen genre and language." }, + "lyrics": { "title": "Generated Lyrics", "desc": "AI wrote these lyrics to match the music.\nCheck the generated lyrics." }, + "audioPlayer": { "title": "Preview the Music", "desc": "Music generation is complete.\nPress play to listen to the generated music." }, + "video": { "title": "Generate Video", "desc": "Click the button to start generating your video." } }, "myInfo": { - "connect": { "title": "Connect YouTube", "desc": "Link your YouTube account to upload videos." }, - "button": { "title": "Connect Now", "desc": "Click to sign in with your Google account." } + "connect": { "title": "Connect Social Account", "desc": "You need to link a social account to upload videos." }, + "button": { "title": "Connect Now", "desc": "Click the social media button you want to connect." }, + "connected": { "title": "Connected Accounts", "desc": "Your linked social accounts appear here. Check after connecting." }, + "ado2": { "title": "Check My Contents", "desc": "After connecting, go to My Contents in the sidebar to view and upload your videos. Click to navigate." } }, "ado2": { "list": { "title": "Generated Videos", "desc": "View all AI-created videos here." }, - "upload": { "title": "Upload to YouTube", "desc": "Select a video and upload it to YouTube." } + "upload": { "title": "Upload to Social Media", "desc": "Select a video and upload it to social media." } }, "completion": { - "upload": { "title": "Upload to YouTube", "desc": "Click the button to upload your video to YouTube." }, + "contentInfo": { "title": "Content Info", "desc": "Check the title, genre, resolution, and lyrics of the generated content." }, + "generating": { "title": "Generating Video", "desc": "AI is creating your video. Please wait a moment." }, + "completion": { "title": "Video Complete!", "desc": "Your video is ready. Want to take a look?" }, "myInfo": { "title": "Connect Social Account", "desc": "To upload your video to YouTube, connect your social account in My Info. Click to go there." } }, "upload": { - "title": { "title": "Edit Title & Description", "desc": "Enter the title and description for YouTube." }, - "schedule": { "title": "Schedule Upload", "desc": "Upload now or schedule for a specific date." }, - "submit": { "title": "Start Upload", "desc": "Click the button to start uploading to YouTube." } + "seo": { "title": "Title & Description", "desc": "AI is generating the title and description for your video.\nPlease wait a moment." }, + "required": { "title": "Required Fields", "desc": "Fields marked with * are required. Please fill them in before uploading." }, + "schedule": { "title": "Schedule Upload", "desc": "Post now or schedule for a specific time." }, + "submit": { "title": "Start Upload", "desc": "Click the Post button to start uploading." } }, "dashboard": { - "metrics": { "title": "Key Metrics", "desc": "Check views, subscribers, and other stats." }, - "chart": { "title": "Growth Chart", "desc": "Track your channel's growth over time." } + "metrics": { "title": "Key Metrics", "desc": "Check views, subscribers, and other stats for content uploaded via ADO2." }, + "chart": { "title": "Growth Chart", "desc": "Track your channel's growth over time." }, + "more": { "title": "More Analytics", "desc": "Even more statistics are available at a glance on the dashboard." } + }, + "contentCalendar": { + "grid": { "title": "Content Calendar", "desc": "View your content schedule by date.\nWhy not select today?" }, + "panel": { "title": "Content List", "desc": "Check the detailed content schedule here." } + }, + "feedback": { + "complete": { "title": "Tutorial Complete 🎉", "desc": "You've completed everything from brand analysis to YouTube upload. Now give it a try on your own!" }, + "title": "Customer Feedback", + "desc": "Share any issues or suggestions to help us improve." }, - "feedback": { "title": "Customer Feedback", "desc": "Share your thoughts to help us improve." }, "restart": { "title": "Restart Tutorial?", "desc": "The tutorial will restart from the current screen.", diff --git a/src/locales/ko.json b/src/locales/ko.json index 8236575..5268b32 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -21,53 +21,68 @@ "next": "다음", "prev": "이전", "finish": "완료", - "clickToProceed": "위 항목을 직접 선택하면 다음으로 넘어갑니다.", - "clickToFinish": "위 항목을 직접 선택하면 튜토리얼이 완료됩니다.", "landing": { "dropdown": { "title": "검색 방식 선택", "desc": "URL 또는 업체명 중 원하는 방식을 선택하세요." }, - "field": { "title": "URL 또는 업체명 입력", "desc": "네이버 지도에서 장소를 검색하고 공유 클릭하여 나온 URL을 붙여넣거나 업체명을 입력하고 선택하세요." }, + "field": { "title": "URL 또는 업체명 입력", "desc": "네이버 지도에서 장소를 검색하고 공유를 클릭하여 나온 URL을 붙여넣거나 업체명을 입력하고 선택하세요." }, "button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." } }, "analysis": { - "identity": { "title": "브랜드 정체성", "desc": "AI가 분석한 브랜드의 핵심 가치와 시장 포지셔닝을 확인하세요." }, - "selling": { "title": "주요 셀링 포인트", "desc": "브랜드의 강점을 확인할 수 있어요." }, - "persona": { "title": "주요 고객 유형", "desc": "어떤 고객이 이 브랜드를 찾는지 분석했어요." }, + "identity": { "title": "브랜드 정체성", "desc": "AI가 분석한 펜션의 핵심 가치와 시장 포지셔닝을 확인하세요." }, + "selling": { "title": "주요 셀링 포인트", "desc": "펜션의 강점을 확인할 수 있어요." }, + "persona": { "title": "주요 고객 유형", "desc": "어떤 고객이 이 펜션을 찾는지 분석했어요." }, "keywords": { "title": "추천 타겟 키워드", "desc": "고객이 검색할 가능성이 높은 키워드들이에요." }, - "generate": { "title": "콘텐츠 생성", "desc": "분석 결과를 바탕으로 영상 콘텐츠를 만들어 보세요. 버튼을 클릭하면 카카오 로그인으로 이동합니다." } + "generate": { "title": "콘텐츠 생성", "desc": "분석 결과를 바탕으로 영상을 만들어 보세요.\n클릭하면 카카오 로그인으로 이동합니다." } }, "asset": { - "upload": { "title": "이미지 추가", "desc": "브랜드 이미지를 추가하거나 삭제할 수 있어요." }, - "ratio": { "title": "영상 비율 선택", "desc": "유튜브에 최적화된 비율을 선택하세요." }, + "image": { "title": "이미지 목록", "desc": "네이버 Place에서 사진이에요. 더보기를 누르면 나머지 사진도 볼 수 있고 X를 눌러 삭제 할 수 있어요." }, + "upload": { "title": "이미지 추가", "desc": "이미지를 자유롭게 추가 할 수 있어요." }, + "ratio": { "title": "영상 비율 선택", "desc": "생성 할 영상의 비율을 선택하세요." }, "next": { "title": "다음 단계로", "desc": "설정이 완료되면 다음 단계로 진행하세요." } }, "sound": { "genre": { "title": "장르 선택", "desc": "영상에 어울리는 음악 장르를 선택하세요." }, - "language": { "title": "언어 선택", "desc": "사운드의 언어를 선택하세요." }, - "generate": { "title": "사운드 생성", "desc": "AI가 브랜드에 맞는 음악을 생성해요." }, - "video": { "title": "영상 생성", "desc": "사운드 생성 후 영상을 만들 수 있어요." } + "language": { "title": "언어 선택", "desc": "사운드의 언어를 선택하세요. 다음을 눌러주세요." }, + "generate": { "title": "사운드 생성", "desc": "AI가 선택한 장르와 언어로 가사와 음악을 생성해요." }, + "lyrics": { "title": "생성된 가사", "desc": "AI가 음악에 맞는 가사를 만들었어요.\n생성된 가사를 확인하세요." }, + "audioPlayer": { "title": "음악 미리 듣기", "desc": "음악 생성이 완료되었어요.\n재생 버튼을 눌러 생성된 음악을 미리 들어보세요." }, + "video": { "title": "영상 생성", "desc": "버튼을 클릭해서 영상 생성을 시작하세요." } }, "myInfo": { "connect": { "title": "소셜 연동", "desc": "영상을 업로드하려면 소셜 계정을 연동해야 해요." }, - "button": { "title": "연동하기", "desc": "원하는 소셜미디어 버튼을 클릭해서 연동하세요." } + "button": { "title": "연동하기", "desc": "원하는 소셜미디어 버튼을 클릭해서 연동하세요." }, + "connected": { "title": "연동된 계정", "desc": "연동된 소셜 계정 목록이에요. 연동 후 여기서 확인할 수 있어요." }, + "ado2": { "title": "내 콘텐츠 확인", "desc": "연동 후 내 콘텐츠 메뉴에서 생성된 영상을 확인하고 업로드할 수 있어요. 클릭해서 이동하세요." } }, "ado2": { "list": { "title": "생성된 영상 목록", "desc": "ADO2에서 만든 영상들을 여기서 확인할 수 있어요." }, - "upload": { "title": "소셜 업로드", "desc": "원하는 영상을 선택해서 소셜미디어에 업로드하세요." } + "upload": { "title": "소셜 업로드", "desc": "선택해서 소셜미디어에 업로드하세요." } }, "completion": { - "upload": { "title": "업로드", "desc": "버튼을 눌러 완성된 영상을 업로드하세요." }, + "contentInfo": { "title": "콘텐츠 정보", "desc": "생성된 콘텐츠의 제목, 장르, 규격, 가사 정보를 확인하세요." }, + "generating": { "title": "영상 제작 중", "desc": "AI가 영상을 만들고 있어요. 잠시만 기다려 주세요." }, + "completion": { "title": "영상 완성!", "desc": "영상 제작이 완료되었어요. 영상을 확인해 볼까요?" }, "myInfo": { "title": "소셜 계정 연동", "desc": "영상을 유튜브에 업로드하려면 내 정보에서 소셜 계정을 연동해야 해요. 클릭해서 이동하세요." } }, "upload": { - "title": { "title": "제목 및 설명 입력", "desc": "유튜브에 표시될 제목과 설명을 입력하세요." }, - "schedule": { "title": "업로드 예약", "desc": "즉시 업로드하거나 원하는 날짜에 예약할 수 있어요." }, - "submit": { "title": "업로드 시작", "desc": "버튼을 눌러 유튜브 업로드를 시작하세요." } + "seo": { "title": "제목 및 설명", "desc": "영상에 표시될 제목과 설명을 AI가 만들고 있어요. \n잠시만 기다려 주세요." }, + "required": { "title": "필수 항목", "desc": "영상을 업로드 하기 전 *는 필수항목으로 반드시 확인해 주세요." }, + "schedule": { "title": "업로드 예약", "desc": "지금 게시하거나 원하는 시간에 예약할 수 있어요." }, + "submit": { "title": "업로드 시작", "desc": "게시 버튼을 눌러 업로드를 시작하세요." } }, "dashboard": { - "metrics": { "title": "핵심 지표", "desc": "조회수, 구독자 등 채널의 주요 통계를 확인하세요." }, - "chart": { "title": "성장 추이 차트", "desc": "기간별 성장 추이를 그래프로 확인할 수 있어요." } + "metrics": { "title": "핵심 지표", "desc": "조회수, 구독자 등 ADO2로 업로드한 콘텐츠의 주요 통계를 확인하세요." }, + "chart": { "title": "성장 추이 차트", "desc": "기간별 성장 추이를 그래프로 확인할 수 있어요." }, + "more": { "title": "더 많은 통계", "desc": "그 외에도 다양한 통계를 대시보드에서 한눈에 확인할 수 있어요." } + }, + "contentCalendar": { + "grid": { "title": "콘텐츠 캘린더", "desc": "날짜별로 콘텐츠 스케줄을 확인할 수 있어요. \n오늘 날짜를 선택해 볼까요?" }, + "panel": { "title": "콘텐츠 목록", "desc": "자세한 콘텐츠 스케줄을 확인 할수 있어요." } + }, + "feedback": { + "complete": { "title": "튜토리얼 완료 🎉", "desc": "브랜드 분석부터 유튜브 업로드까지 모든 과정을 완료했어요. 이제 직접 시작해 보세요!" }, + "title": "고객의견", + "desc": "서비스 이용 중 불편한 점이나 개선 의견을 보내주세요." }, - "feedback": { "title": "고객의견", "desc": "서비스 이용 중 불편한 점이나 개선 의견을 보내주세요." }, "restart": { "title": "튜토리얼을 다시 시작할까요?", "desc": "현재 화면부터 튜토리얼이 다시 시작됩니다.", diff --git a/src/pages/Dashboard/CompletionContent.tsx b/src/pages/Dashboard/CompletionContent.tsx index 8c5c141..d444a42 100755 --- a/src/pages/Dashboard/CompletionContent.tsx +++ b/src/pages/Dashboard/CompletionContent.tsx @@ -44,12 +44,17 @@ const CompletionContent: React.FC = ({ const tutorial = useTutorial(); - // 영상 완료 시 튜토리얼 트리거 + // 영상 생성 중 튜토리얼 트리거 (생성 상태 안내 -> 콘텐츠 정보 -> 내 정보 이동) useEffect(() => { - if (videoStatus === 'complete' && !tutorial.hasSeen(TUTORIAL_KEYS.COMPLETION)) { + const isComplete = videoStatus === 'complete'; + const isProcessing = videoStatus === 'generating' || videoStatus === 'polling'; + + if (isProcessing && !tutorial.isActive && !tutorial.hasSeen(TUTORIAL_KEYS.GENERATING)) { + tutorial.startTutorial(TUTORIAL_KEYS.GENERATING); + } else if (isComplete && !tutorial.isActive && !tutorial.hasSeen(TUTORIAL_KEYS.COMPLETION)) { tutorial.startTutorial(TUTORIAL_KEYS.COMPLETION); } - }, [videoStatus]); + }, [videoStatus, tutorial]); // 소셜 미디어 포스팅 모달 const [showSocialModal, setShowSocialModal] = useState(false); diff --git a/src/pages/Dashboard/ContentCalendarContent.tsx b/src/pages/Dashboard/ContentCalendarContent.tsx index 0c13bdb..7e4b20c 100644 --- a/src/pages/Dashboard/ContentCalendarContent.tsx +++ b/src/pages/Dashboard/ContentCalendarContent.tsx @@ -758,7 +758,7 @@ const ContentCalendarContent: React.FC = ({ onNavig flex: 1, minHeight: 0, }}> {/* 캘린더 영역 */} -
= ({ onNavig
{/* 오른쪽 패널 */} -
= ({ localStorage.setItem(ACTIVE_ITEM_KEY, activeItem); }, [activeItem]); - // wizardStep 변경 시 튜토리얼 트리거 + // wizardStep 변경 시 튜토리얼 트리거 (사이드바 메뉴 화면에서는 제외) + const SIDEBAR_ITEMS = ['대시보드', 'ADO2 콘텐츠', '내 정보', '콘텐츠 캘린더']; useEffect(() => { + if (SIDEBAR_ITEMS.includes(activeItem)) return; const timer = setTimeout(() => { if (wizardStep === 1 && !tutorial.hasSeen(TUTORIAL_KEYS.ASSET)) { tutorial.startTutorial(TUTORIAL_KEYS.ASSET); @@ -330,7 +332,7 @@ const GenerationFlow: React.FC = ({ } }, 600); return () => clearTimeout(timer); - }, [wizardStep]); + }, [wizardStep, activeItem]); // activeItem 변경 시 튜토리얼 트리거 useEffect(() => { @@ -340,6 +342,8 @@ const GenerationFlow: React.FC = ({ tutorial.startTutorial(TUTORIAL_KEYS.ADO2_CONTENTS); } else if (activeItem === '대시보드' && !tutorial.hasSeen(TUTORIAL_KEYS.DASHBOARD)) { tutorial.startTutorial(TUTORIAL_KEYS.DASHBOARD); + } else if (activeItem === '콘텐츠 캘린더' && !tutorial.hasSeen(TUTORIAL_KEYS.CONTENT_CALENDAR)) { + tutorial.startTutorial(TUTORIAL_KEYS.CONTENT_CALENDAR); } }, [activeItem]); diff --git a/src/pages/Dashboard/SoundStudioContent.tsx b/src/pages/Dashboard/SoundStudioContent.tsx index 9fc2dd1..451ec89 100755 --- a/src/pages/Dashboard/SoundStudioContent.tsx +++ b/src/pages/Dashboard/SoundStudioContent.tsx @@ -3,6 +3,9 @@ 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; @@ -43,6 +46,7 @@ const SoundStudioContent: React.FC = ({ videoGenerationProgress = 0 }) => { const { t } = useTranslation(); + const tutorial = useTutorial(); const [selectedType, setSelectedType] = useState('보컬'); const [selectedLang, setSelectedLang] = useState('한국어'); const [selectedGenre, setSelectedGenre] = useState('자동 선택'); @@ -389,7 +393,28 @@ const SoundStudioContent: React.FC = ({ const isGenerating = status === 'generating_lyric' || status === 'generating_song' || status === 'polling'; + // 가사 생성 완료 시 가사 튜토리얼 트리거 + 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); + } + }, [status]); + return ( + <>
{audioUrl && (
+ + {tutorial.isActive && ( + + )} + ); };