chore: 튜토리얼 오버레이와 관련 문구를 제거
랜딩·업로드에서 자동 안내가 더 이상 뜨지 않게 한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
4fe065378f
commit
82b9fef3a1
19
src/App.tsx
19
src/App.tsx
@ -15,7 +15,7 @@ import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback';
|
||||
import ADO2ContentsPage from './pages/Dashboard/ADO2ContentsPage';
|
||||
import VideoDetailPage from './components/VideoDetailPage';
|
||||
import SsulDetailPage from './pages/Ssulbox/SsulDetailPage';
|
||||
import { crawlUrl, autocomplete, marketingAnalysis, kakaoCallback, isLoggedIn, saveTokens, getVideosList, AutocompleteRequest, storeUtmFromUrl, trackViewContent, trackCompleteRegistration } from './utils/api';
|
||||
import { crawlUrl, autocomplete, marketingAnalysis, kakaoCallback, isLoggedIn, saveTokens, AutocompleteRequest, storeUtmFromUrl, trackViewContent, trackCompleteRegistration } from './utils/api';
|
||||
import { saveSearchHistory } from './components/SearchHistory/useSearchHistory';
|
||||
import { CrawlingResponse } from './types/api';
|
||||
|
||||
@ -104,23 +104,6 @@ const App: React.FC = () => {
|
||||
const [afterLoadTarget, setAfterLoadTarget] = useState<ViewMode>('analysis');
|
||||
const [scrollProgress, setScrollProgress] = useState(0);
|
||||
const [isProcessingCallback, setIsProcessingCallback] = useState(false);
|
||||
const tutorialVideoCheckedRef = useRef(false);
|
||||
|
||||
// generation_flow 진입 시 영상 보유 여부 확인 → 있으면 튜토리얼 자동 off
|
||||
useEffect(() => {
|
||||
if (viewMode !== 'generation_flow') return;
|
||||
if (tutorialVideoCheckedRef.current) return;
|
||||
tutorialVideoCheckedRef.current = true;
|
||||
const ENABLED_KEY = 'ado2_tutorial_enabled';
|
||||
if (localStorage.getItem(ENABLED_KEY) !== null) return;
|
||||
getVideosList(1, 1).then(response => {
|
||||
const hasVideos = response.items.some(v => v.result_movie_url?.trim());
|
||||
if (hasVideos) {
|
||||
localStorage.setItem(ENABLED_KEY, 'false');
|
||||
window.dispatchEvent(new Event('ado2-tutorial-auto-disable'));
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, [viewMode]);
|
||||
|
||||
// 카카오 로그인 콜백 처리 (URL에서 토큰 또는 code 파라미터 확인)
|
||||
useEffect(() => {
|
||||
|
||||
@ -5,9 +5,6 @@ import { getSocialAccounts, uploadToSocial, waitForUploadComplete, TokenExpiredE
|
||||
import { SocialAccount, VideoListItem, SocialUploadStatusResponse } from '../types/api';
|
||||
import UploadProgressModal, { UploadStatus } from './UploadProgressModal';
|
||||
import { useOverlayClose } from '../hooks/useOverlayClose';
|
||||
import { useTutorial } from './Tutorial/useTutorial';
|
||||
import { TUTORIAL_KEYS } from './Tutorial/tutorialSteps';
|
||||
import TutorialOverlay from './Tutorial/TutorialOverlay';
|
||||
|
||||
interface SocialPostingModalProps {
|
||||
isOpen: boolean;
|
||||
@ -122,8 +119,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||
onGoToCalendar,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const tutorial = useTutorial();
|
||||
const hasBeenOpenedRef = useRef(false);
|
||||
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
|
||||
const [selectedChannel, setSelectedChannel] = useState<string>('');
|
||||
const [title, setTitle] = useState('');
|
||||
@ -185,32 +180,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [isOpen]);
|
||||
|
||||
// 모달 오픈/닫힘 시 튜토리얼 트리거
|
||||
useEffect(() => {
|
||||
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_FORM);
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isLoadingAutoDescription]);
|
||||
|
||||
// 소셜 계정 로드 + SEO 자동 채움
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
@ -874,16 +843,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
{uploadProgressModalElement}
|
||||
{tutorial.isActive && (
|
||||
<TutorialOverlay
|
||||
hints={tutorial.hints}
|
||||
currentIndex={tutorial.currentHintIndex}
|
||||
onNext={tutorial.nextHint}
|
||||
onPrev={tutorial.prevHint}
|
||||
onSkip={tutorial.skipTutorial}
|
||||
groupProgress={tutorial.groupProgress}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,314 +0,0 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TutorialHint } from './tutorialSteps';
|
||||
|
||||
interface Rect {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
interface TooltipPos {
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
interface TutorialOverlayProps {
|
||||
hints: TutorialHint[];
|
||||
currentIndex: number;
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
onSkip: () => void;
|
||||
groupProgress?: { groupTotal: number; groupOffset: number; isLastKeyInGroup: boolean } | null;
|
||||
}
|
||||
|
||||
const PADDING = 8;
|
||||
|
||||
function getTargetRect(selector: string): Rect | null {
|
||||
const els = Array.from(document.querySelectorAll(selector));
|
||||
const el = els.find(e => {
|
||||
const r = (e as HTMLElement).getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
}) ?? els[0];
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
return { top: r.top, left: r.left, width: r.width, height: r.height, bottom: r.bottom };
|
||||
}
|
||||
|
||||
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,
|
||||
bottom,
|
||||
left,
|
||||
width: Math.max(0, right - left),
|
||||
height: Math.max(0, bottom - top),
|
||||
};
|
||||
}
|
||||
|
||||
function calcTooltipPos(rect: Rect, position: TutorialHint['position'], tooltipW = 300, tooltipH = 160): TooltipPos {
|
||||
|
||||
switch (position) {
|
||||
case 'bottom':
|
||||
return {
|
||||
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: 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.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.min(Math.max(rect.top + rect.height / 2 - tooltipH / 2, 8), window.innerHeight - tooltipH - 8),
|
||||
left: Math.max(rect.left - tooltipW - PADDING, 8),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const TutorialOverlay: React.FC<TutorialOverlayProps> = ({
|
||||
hints,
|
||||
currentIndex,
|
||||
onNext,
|
||||
onPrev,
|
||||
onSkip,
|
||||
groupProgress,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [targetRect, setTargetRect] = useState<Rect | null>(null);
|
||||
const tooltipRef = React.useRef<HTMLDivElement>(null);
|
||||
const [tooltipSize, setTooltipSize] = useState({ w: 300, h: 160 });
|
||||
|
||||
const hint = hints[currentIndex];
|
||||
const isLast = currentIndex === hints.length - 1;
|
||||
// 그룹이 있으면 그룹의 마지막 키 + 마지막 힌트일 때만 완료, 그룹 없으면 기존대로
|
||||
const isFinish = isLast && (groupProgress ? groupProgress.isLastKeyInGroup : true);
|
||||
|
||||
const updateRect = useCallback(() => {
|
||||
if (!hint) return;
|
||||
setTargetRect(getTargetRect(hint.targetSelector));
|
||||
}, [hint]);
|
||||
|
||||
useEffect(() => {
|
||||
updateRect();
|
||||
window.addEventListener('resize', updateRect);
|
||||
window.addEventListener('scroll', updateRect, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateRect);
|
||||
window.removeEventListener('scroll', updateRect, true);
|
||||
};
|
||||
}, [updateRect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hint) return;
|
||||
let retryTimer: number | undefined;
|
||||
let rectTimer: number | undefined;
|
||||
let cleanupTarget: (() => void) | undefined;
|
||||
|
||||
const bindToTarget = (el: HTMLElement) => {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
rectTimer = window.setTimeout(updateRect, 200);
|
||||
|
||||
const shouldClickAdvance = hint.clickToAdvance !== false;
|
||||
if (shouldClickAdvance) {
|
||||
el.style.cursor = 'pointer';
|
||||
el.addEventListener('click', onNext);
|
||||
cleanupTarget = () => {
|
||||
el.style.cursor = '';
|
||||
el.removeEventListener('click', onNext);
|
||||
};
|
||||
} else {
|
||||
cleanupTarget = () => {};
|
||||
}
|
||||
};
|
||||
|
||||
const tryBind = (): boolean => {
|
||||
const els = Array.from(document.querySelectorAll(hint.targetSelector));
|
||||
const el = (els.find(e => {
|
||||
const r = (e as HTMLElement).getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
}) ?? els[0]) 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 () => {
|
||||
if (retryTimer) window.clearInterval(retryTimer);
|
||||
if (rectTimer) window.clearTimeout(rectTimer);
|
||||
cleanupTarget?.();
|
||||
};
|
||||
}, [hint, updateRect, onNext]);
|
||||
|
||||
// 툴팁 DOM 크기 측정 — 힌트가 바뀔 때만 재측정
|
||||
useEffect(() => {
|
||||
const el = tooltipRef.current;
|
||||
if (!el) return;
|
||||
const { offsetWidth, offsetHeight } = el;
|
||||
if (offsetWidth && offsetHeight) {
|
||||
setTooltipSize(prev =>
|
||||
prev.w === offsetWidth && prev.h === offsetHeight
|
||||
? prev
|
||||
: { w: offsetWidth, h: offsetHeight }
|
||||
);
|
||||
}
|
||||
}, [currentIndex, hints]);
|
||||
|
||||
if (!hint) return null;
|
||||
|
||||
const isTargetVisible = targetRect
|
||||
? targetRect.top + 60 < window.innerHeight && targetRect.bottom > 60
|
||||
: true;
|
||||
|
||||
if (!isTargetVisible) return null;
|
||||
|
||||
const spotlightPadding = hint.spotlightPadding ?? PADDING;
|
||||
const spotlightRect = (targetRect && !hint.noSpotlight) ? getSpotlightRect(targetRect, spotlightPadding, hint.spotlightPaddingOverride) : null;
|
||||
|
||||
// 툴팁은 spotlightRect 기준으로 배치해야 스포트라이트와 겹치지 않음
|
||||
const tooltipPos: TooltipPos = (spotlightRect ?? targetRect)
|
||||
? calcTooltipPos((spotlightRect ?? targetRect)!, hint.position, tooltipSize.w, tooltipSize.h)
|
||||
: {
|
||||
top: window.innerHeight / 2 - tooltipSize.h / 2,
|
||||
left: window.innerWidth / 2 - tooltipSize.w / 2,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tutorial-overlay-root">
|
||||
{spotlightRect ? (
|
||||
<>
|
||||
<div
|
||||
className="tutorial-overlay-blocker"
|
||||
style={{ top: 0, left: 0, right: 0, height: spotlightRect.top }}
|
||||
/>
|
||||
<div
|
||||
className="tutorial-overlay-blocker"
|
||||
style={{
|
||||
top: spotlightRect.top,
|
||||
left: 0,
|
||||
width: spotlightRect.left,
|
||||
height: spotlightRect.height,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="tutorial-overlay-blocker"
|
||||
style={{
|
||||
top: spotlightRect.top,
|
||||
left: spotlightRect.left + spotlightRect.width,
|
||||
right: 0,
|
||||
height: spotlightRect.height,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="tutorial-overlay-blocker"
|
||||
style={{
|
||||
top: spotlightRect.top + spotlightRect.height,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="tutorial-spotlight-ring"
|
||||
style={{
|
||||
top: spotlightRect.top,
|
||||
left: spotlightRect.left,
|
||||
width: spotlightRect.width,
|
||||
height: spotlightRect.height,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className={`tutorial-tooltip${hint.variant === 'bubble' ? ` tutorial-tooltip-bubble tutorial-tooltip-bubble--${hint.position}` : ''}`}
|
||||
style={{ top: tooltipPos.top, left: tooltipPos.left }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p className="tutorial-tooltip-title">{t(hint.titleKey)}</p>
|
||||
<p className="tutorial-tooltip-desc">{t(hint.descriptionKey)}</p>
|
||||
{hint.noteKey && (
|
||||
<p className="tutorial-tooltip-note">{t(hint.noteKey)}</p>
|
||||
)}
|
||||
|
||||
<div className="tutorial-tooltip-footer">
|
||||
<span className="tutorial-tooltip-counter">
|
||||
{groupProgress ? groupProgress.groupOffset + 1 : currentIndex + 1} / {groupProgress ? groupProgress.groupTotal : hints.length}
|
||||
</span>
|
||||
<div className="tutorial-tooltip-actions">
|
||||
<button className="tutorial-btn-skip" onClick={onSkip}>
|
||||
{t('tutorial.skip')}
|
||||
</button>
|
||||
{currentIndex > 0 && (
|
||||
<button className="tutorial-btn-prev" onClick={onPrev}>
|
||||
{t('tutorial.prev')}
|
||||
</button>
|
||||
)}
|
||||
{(hint.clickToAdvance !== true || isFinish) && (
|
||||
<button className="tutorial-btn-next" onClick={onNext}>
|
||||
{isFinish ? t('tutorial.finish') : t('tutorial.next')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TutorialOverlay;
|
||||
|
||||
interface TutorialRestartPopupProps {
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const TutorialRestartPopup: React.FC<TutorialRestartPopupProps> = ({ onConfirm, onCancel }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="tutorial-restart-backdrop">
|
||||
<div className="tutorial-restart-popup" onClick={(e) => e.stopPropagation()}>
|
||||
<p className="tutorial-restart-title">{t('tutorial.restart.title')}</p>
|
||||
<p className="tutorial-restart-desc">{t('tutorial.restart.desc')}</p>
|
||||
<div className="tutorial-restart-actions">
|
||||
<button className="tutorial-restart-cancel" onClick={onCancel}>
|
||||
{t('tutorial.restart.cancel')}
|
||||
</button>
|
||||
<button className="tutorial-restart-confirm" onClick={onConfirm}>
|
||||
{t('tutorial.restart.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,377 +0,0 @@
|
||||
export interface TutorialHint {
|
||||
targetSelector: string;
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
position: 'top' | 'bottom' | 'left' | 'right';
|
||||
noteKey?: string;
|
||||
variant?: 'bubble';
|
||||
clickToAdvance?: boolean;
|
||||
advanceSelector?: string;
|
||||
noSpotlight?: boolean;
|
||||
spotlightPadding?: number;
|
||||
spotlightPaddingOverride?: { top?: number; right?: number; bottom?: number; left?: number };
|
||||
}
|
||||
|
||||
export interface TutorialStepDef {
|
||||
key: string;
|
||||
hints: TutorialHint[];
|
||||
}
|
||||
|
||||
export const TUTORIAL_KEYS = {
|
||||
LANDING: 'landing',
|
||||
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;
|
||||
|
||||
// 같은 페이지에 속하는 튜토리얼 키 그룹 — 진행 카운터를 합산해서 표시
|
||||
export const TUTORIAL_PAGE_GROUPS: string[][] = [
|
||||
[TUTORIAL_KEYS.SOUND, TUTORIAL_KEYS.SOUND_LYRICS, TUTORIAL_KEYS.SOUND_AUDIO],
|
||||
[TUTORIAL_KEYS.GENERATING, TUTORIAL_KEYS.COMPLETION],
|
||||
[TUTORIAL_KEYS.UPLOAD_MODAL, TUTORIAL_KEYS.UPLOAD_FORM],
|
||||
];
|
||||
|
||||
export const tutorialSteps: TutorialStepDef[] = [
|
||||
{
|
||||
key: TUTORIAL_KEYS.LANDING,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.hero-input-wrapper',
|
||||
titleKey: 'tutorial.landing.field.title',
|
||||
descriptionKey: 'tutorial.landing.field.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
noSpotlight: true,
|
||||
variant: 'bubble',
|
||||
},
|
||||
{
|
||||
targetSelector: '.hero-manual-card-title',
|
||||
titleKey: 'tutorial.landing.manual.title',
|
||||
descriptionKey: 'tutorial.landing.manual.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
noSpotlight: true,
|
||||
variant: 'bubble',
|
||||
},
|
||||
{
|
||||
targetSelector: '.hero-button',
|
||||
titleKey: 'tutorial.landing.button.title',
|
||||
descriptionKey: 'tutorial.landing.button.desc',
|
||||
position: 'bottom',
|
||||
clickToAdvance: true,
|
||||
noSpotlight: true,
|
||||
variant: 'bubble',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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, .asset-mobile-upload-btn',
|
||||
titleKey: 'tutorial.asset.upload.title',
|
||||
descriptionKey: 'tutorial.asset.upload.desc',
|
||||
position: 'bottom',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.asset-ratio-section',
|
||||
titleKey: 'tutorial.asset.ratio.title',
|
||||
descriptionKey: 'tutorial.asset.ratio.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.asset-next-button',
|
||||
titleKey: 'tutorial.asset.next.title',
|
||||
descriptionKey: 'tutorial.asset.next.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.SOUND,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.genre-grid',
|
||||
titleKey: 'tutorial.sound.genre.title',
|
||||
descriptionKey: 'tutorial.sound.genre.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.language-grid',
|
||||
titleKey: 'tutorial.sound.language.title',
|
||||
descriptionKey: 'tutorial.sound.language.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.btn-generate-sound',
|
||||
titleKey: 'tutorial.sound.generate.title',
|
||||
descriptionKey: 'tutorial.sound.generate.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.SOUND_LYRICS,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.lyrics-display',
|
||||
titleKey: 'tutorial.sound.lyrics.title',
|
||||
descriptionKey: 'tutorial.sound.lyrics.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.status-message-new',
|
||||
titleKey: 'tutorial.sound.lyricsWait.title',
|
||||
descriptionKey: 'tutorial.sound.lyricsWait.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.SOUND_AUDIO,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.audio-player',
|
||||
titleKey: 'tutorial.sound.audioPlayer.title',
|
||||
descriptionKey: 'tutorial.sound.audioPlayer.desc',
|
||||
position: 'bottom',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.btn-video-generate',
|
||||
titleKey: 'tutorial.sound.video.title',
|
||||
descriptionKey: 'tutorial.sound.video.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.MY_INFO,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.youtube-connect-section',
|
||||
titleKey: 'tutorial.myInfo.myInfo.title',
|
||||
descriptionKey: 'tutorial.myInfo.myInfo.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.myinfo-social-btn',
|
||||
titleKey: 'tutorial.myInfo.connect.title',
|
||||
descriptionKey: 'tutorial.myInfo.connect.desc',
|
||||
noteKey: 'tutorial.myInfo.connect.note',
|
||||
position: 'top',
|
||||
clickToAdvance: true,
|
||||
},
|
||||
{
|
||||
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-content-card',
|
||||
titleKey: 'tutorial.ado2.list.title',
|
||||
descriptionKey: 'tutorial.ado2.list.desc',
|
||||
position: 'right',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.content-upload-btn',
|
||||
titleKey: 'tutorial.ado2.download.title',
|
||||
descriptionKey: 'tutorial.ado2.download.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.content-delete-btn',
|
||||
titleKey: 'tutorial.ado2.delete.title',
|
||||
descriptionKey: 'tutorial.ado2.delete.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.content-download-btn',
|
||||
titleKey: 'tutorial.ado2.upload.title',
|
||||
descriptionKey: 'tutorial.ado2.upload.desc',
|
||||
position: 'top',
|
||||
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: 'top',
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.COMPLETION,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.comp2-video-section',
|
||||
titleKey: 'tutorial.completion.completion.title',
|
||||
descriptionKey: 'tutorial.completion.completion.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '#sidebar-my-info',
|
||||
titleKey: 'tutorial.completion.myInfo.title',
|
||||
descriptionKey: 'tutorial.completion.myInfo.desc',
|
||||
position: 'right',
|
||||
clickToAdvance: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.UPLOAD_MODAL,
|
||||
hints: [
|
||||
{
|
||||
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: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.social-posting-radio-group',
|
||||
titleKey: 'tutorial.upload.schedule.title',
|
||||
descriptionKey: 'tutorial.upload.schedule.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.social-posting-btn:not(.cancel)',
|
||||
titleKey: 'tutorial.upload.submit.title',
|
||||
descriptionKey: 'tutorial.upload.submit.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.DASHBOARD,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.stats-grid-8',
|
||||
titleKey: 'tutorial.dashboard.metrics.title',
|
||||
descriptionKey: 'tutorial.dashboard.metrics.desc',
|
||||
position: 'bottom',
|
||||
},
|
||||
{
|
||||
targetSelector: '.yoy-chart-card',
|
||||
titleKey: 'tutorial.dashboard.chart.title',
|
||||
descriptionKey: 'tutorial.dashboard.chart.desc',
|
||||
position: 'top',
|
||||
},
|
||||
{
|
||||
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: false,
|
||||
},
|
||||
{
|
||||
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',
|
||||
descriptionKey: 'tutorial.feedback.desc',
|
||||
position: 'right',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@ -1,220 +0,0 @@
|
||||
import React, { useState, useCallback, useEffect } 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';
|
||||
const ENABLED_KEY = 'ado2_tutorial_enabled';
|
||||
|
||||
// 전역 단일 활성 튜토리얼 관리 — 새 튜토리얼 시작 시 이전 것을 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;
|
||||
isEnabled: boolean;
|
||||
isRestartPopupVisible: boolean;
|
||||
currentHintIndex: number;
|
||||
hints: TutorialHint[];
|
||||
tutorialKey: string | null;
|
||||
groupProgress: { groupTotal: number; groupOffset: number; isLastKeyInGroup: boolean } | null;
|
||||
startTutorial: (key: string, onComplete?: () => void, forceFromStart?: boolean) => void;
|
||||
nextHint: () => void;
|
||||
prevHint: () => void;
|
||||
skipTutorial: () => void;
|
||||
toggleTutorial: (currentKey: string | null) => void;
|
||||
showRestartPopup: (key: string) => void;
|
||||
confirmRestart: () => void;
|
||||
cancelRestart: () => void;
|
||||
hasSeen: (key: string) => boolean;
|
||||
}
|
||||
|
||||
export function useTutorial(): UseTutorialReturn {
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [isEnabled, setIsEnabled] = useState(() => localStorage.getItem(ENABLED_KEY) !== '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) => {
|
||||
if (localStorage.getItem(ENABLED_KEY) === 'false') return;
|
||||
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 toggleTutorial = useCallback((currentKey: string | null) => {
|
||||
if (isEnabled) {
|
||||
// off: 튜토리얼 중단 + 비활성화
|
||||
setIsActive(false);
|
||||
setIsEnabled(false);
|
||||
localStorage.setItem(ENABLED_KEY, 'false');
|
||||
} else {
|
||||
// on: seen/progress 초기화 + 현재 화면 튜토리얼 시작
|
||||
localStorage.removeItem(SEEN_KEY);
|
||||
localStorage.removeItem(PROGRESS_KEY);
|
||||
localStorage.setItem(ENABLED_KEY, 'true');
|
||||
setIsEnabled(true);
|
||||
if (currentKey) {
|
||||
startTutorial(currentKey, undefined, true);
|
||||
}
|
||||
}
|
||||
}, [isEnabled, startTutorial]);
|
||||
|
||||
// 튜토리얼 다시 보기: 팝업 표시만
|
||||
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);
|
||||
}, []);
|
||||
|
||||
// 영상 보유 사용자 자동 off 이벤트 수신
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setIsActive(false);
|
||||
setIsEnabled(false);
|
||||
};
|
||||
window.addEventListener('ado2-tutorial-auto-disable', handler);
|
||||
return () => window.removeEventListener('ado2-tutorial-auto-disable', handler);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isActive,
|
||||
isEnabled,
|
||||
isRestartPopupVisible,
|
||||
currentHintIndex,
|
||||
hints,
|
||||
tutorialKey,
|
||||
groupProgress: tutorialKey ? getGroupProgress(tutorialKey, currentHintIndex) : null,
|
||||
startTutorial,
|
||||
nextHint,
|
||||
prevHint,
|
||||
skipTutorial,
|
||||
toggleTutorial,
|
||||
showRestartPopup,
|
||||
confirmRestart,
|
||||
cancelRestart,
|
||||
hasSeen,
|
||||
};
|
||||
}
|
||||
@ -21,198 +21,10 @@
|
||||
"credits": "Credits left: {{count}}",
|
||||
"loggingOut": "Logging out...",
|
||||
"logout": "Log Out",
|
||||
"tutorialRestart": "Restart Tutorial",
|
||||
"tutorial": "Tutorial",
|
||||
"tutorialOn": "Enable Tutorial",
|
||||
"tutorialOff": "Disable Tutorial",
|
||||
"inquiry": "Feedback",
|
||||
"login": "Log In",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"tutorial": {
|
||||
"skip": "Skip",
|
||||
"next": "Next",
|
||||
"prev": "Back",
|
||||
"finish": "Done",
|
||||
"landing": {
|
||||
"intro": {
|
||||
"title": "Welcome to ADO2 Tutorial",
|
||||
"desc": "We'll guide you through ADO2 step by step."
|
||||
},
|
||||
"field": {
|
||||
"title": "Enter Search Term",
|
||||
"desc": "Paste a Naver Maps share URL,\nor type a business name and select from the list."
|
||||
},
|
||||
"manual": {
|
||||
"title": "Direct Input",
|
||||
"desc": "You can also enter the business name and address manually to start analysis."
|
||||
},
|
||||
"button": {
|
||||
"title": "Start Brand Analysis",
|
||||
"desc": "Click the button to let AI start analyzing your brand."
|
||||
}
|
||||
},
|
||||
"asset": {
|
||||
"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.",
|
||||
"note": "Background music is coming soon."
|
||||
},
|
||||
"language": {
|
||||
"title": "Select Language",
|
||||
"desc": "You can choose the language for the sound.\nWant to continue with Korean?"
|
||||
},
|
||||
"generate": {
|
||||
"title": "Generate Sound",
|
||||
"desc": "Click the button and AI will generate lyrics and music."
|
||||
},
|
||||
"lyrics": {
|
||||
"title": "Lyrics Complete",
|
||||
"desc": "AI wrote lyrics in your selected language.\nCheck the generated lyrics."
|
||||
},
|
||||
"lyricsWait": {
|
||||
"title": "Generating Music",
|
||||
"desc": "AI is composing music based on the lyrics.\nPlease wait a moment."
|
||||
},
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"completion": {
|
||||
"contentInfo": {
|
||||
"title": "Content Info",
|
||||
"desc": "Check the file name, genre, resolution, and lyrics of the generated content."
|
||||
},
|
||||
"generating": {
|
||||
"title": "Generating Video",
|
||||
"desc": "AI is creating your video.\nPlease 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."
|
||||
}
|
||||
},
|
||||
"myInfo": {
|
||||
"myInfo": {
|
||||
"title": "My Info",
|
||||
"desc": "In My Info, you can manage your social connections and view connected accounts."
|
||||
},
|
||||
"connect": {
|
||||
"title": "Connect Now",
|
||||
"desc": "Click the YouTube connect button to go to the connection page.",
|
||||
"note": "Instagram connection is coming soon."
|
||||
},
|
||||
"connected": {
|
||||
"title": "Connected Accounts",
|
||||
"desc": "Your linked social accounts appear here.\nCheck after connecting."
|
||||
},
|
||||
"ado2": {
|
||||
"title": "ADO2 Contents",
|
||||
"desc": "You can now upload the generated video.\nClick to navigate."
|
||||
}
|
||||
},
|
||||
"ado2": {
|
||||
"list": {
|
||||
"title": "Generated Videos",
|
||||
"desc": "View all AI-created videos here."
|
||||
},
|
||||
"download": {
|
||||
"title": "Download",
|
||||
"desc": "Download the video to your device."
|
||||
},
|
||||
"delete": {
|
||||
"title": "Delete",
|
||||
"desc": "Remove videos you no longer need."
|
||||
},
|
||||
"upload": {
|
||||
"title": "Upload to Social Media",
|
||||
"desc": "Select a video and upload it to social media."
|
||||
}
|
||||
},
|
||||
"upload": {
|
||||
"seo": {
|
||||
"title": "Title & Description",
|
||||
"desc": "AI is generating the title and description for your video. Please wait a moment."
|
||||
},
|
||||
"required": {
|
||||
"title": "Required Fields",
|
||||
"desc": "Fields marked with * are required.\nPlease check them 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 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 the full flow from brand analysis to YouTube upload.\nTo replay the tutorial, click the button in the top right."
|
||||
},
|
||||
"title": "Customer Feedback",
|
||||
"desc": "Share any issues or suggestions to help us improve."
|
||||
},
|
||||
"restart": {
|
||||
"title": "Restart Tutorial?",
|
||||
"desc": "The tutorial will restart from the current screen.",
|
||||
"confirm": "Start",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"company": "O2O Inc.",
|
||||
"businessNumber": "Business Registration No. : 620-87-00810 | CEO : Ahn Sungmin",
|
||||
|
||||
@ -21,198 +21,10 @@
|
||||
"credits": "보유 크레딧: {{count}}",
|
||||
"loggingOut": "로그아웃 중...",
|
||||
"logout": "로그아웃",
|
||||
"tutorialRestart": "튜토리얼 다시 보기",
|
||||
"tutorial": "튜토리얼",
|
||||
"tutorialOn": "튜토리얼 켜기",
|
||||
"tutorialOff": "튜토리얼 끄기",
|
||||
"inquiry": "고객의견",
|
||||
"login": "로그인",
|
||||
"settings": "설정"
|
||||
},
|
||||
"tutorial": {
|
||||
"skip": "건너뛰기",
|
||||
"next": "다음",
|
||||
"prev": "이전",
|
||||
"finish": "완료",
|
||||
"landing": {
|
||||
"intro": {
|
||||
"title": "ADO2 튜토리얼 시작",
|
||||
"desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요."
|
||||
},
|
||||
"field": {
|
||||
"title": "입력하기",
|
||||
"desc": "네이버 지도 공유 URL을 붙여넣거나,\n업체명을 입력하면 자동으로 목록에서 선택할 수 있어요."
|
||||
},
|
||||
"manual": {
|
||||
"title": "직접 입력",
|
||||
"desc": "업체명과 주소를 직접 입력해서 분석을 시작할 수도 있어요."
|
||||
},
|
||||
"button": {
|
||||
"title": "브랜드 분석 시작",
|
||||
"desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요."
|
||||
}
|
||||
},
|
||||
"asset": {
|
||||
"image": {
|
||||
"title": "이미지 목록",
|
||||
"desc": "네이버 Place에서 가져 온 사진이에요. \n더보기를 누르면 나머지 사진도 볼 수 있고 X를 눌러 삭제 할 수 있어요."
|
||||
},
|
||||
"upload": {
|
||||
"title": "이미지 추가",
|
||||
"desc": "이미지를 자유롭게 추가 할 수 있어요."
|
||||
},
|
||||
"ratio": {
|
||||
"title": "영상 비율 선택",
|
||||
"desc": "생성 할 영상의 비율을 선택하세요."
|
||||
},
|
||||
"next": {
|
||||
"title": "다음 단계로",
|
||||
"desc": "설정이 완료되면 다음 단계로 진행하세요."
|
||||
}
|
||||
},
|
||||
"sound": {
|
||||
"genre": {
|
||||
"title": "장르 선택",
|
||||
"desc": "영상에 어울리는 음악 장르를 선택하세요.",
|
||||
"note": "배경음악은 이후 오픈 예정입니다."
|
||||
},
|
||||
"language": {
|
||||
"title": "언어 선택",
|
||||
"desc": "음악의 언어를 선택할 수 있어요. \n이미 선택된 한국어로 진행해볼까요?"
|
||||
},
|
||||
"generate": {
|
||||
"title": "사운드 생성",
|
||||
"desc": "버튼을 클릭하면 AI가 가사와 음악을 생성해요."
|
||||
},
|
||||
"lyrics": {
|
||||
"title": "가사 생성 완료",
|
||||
"desc": "AI가 선택한 언어로 가사를 만들었어요.\n생성된 가사를 확인하세요."
|
||||
},
|
||||
"lyricsWait": {
|
||||
"title": "음악 생성 중",
|
||||
"desc": "가사를 바탕으로 AI가 음악을 만들고 있어요.\n잠시만 기다려 주세요."
|
||||
},
|
||||
"audioPlayer": {
|
||||
"title": "음악 미리 듣기",
|
||||
"desc": "음악 생성이 완료되었어요.\n재생 버튼을 눌러 생성된 음악을 들어보세요."
|
||||
},
|
||||
"video": {
|
||||
"title": "영상 생성",
|
||||
"desc": "버튼을 클릭해서 영상 생성을 시작하세요."
|
||||
}
|
||||
},
|
||||
"completion": {
|
||||
"contentInfo": {
|
||||
"title": "콘텐츠 정보",
|
||||
"desc": "콘텐츠의 파일명, 장르, 규격, 가사를 확인하세요."
|
||||
},
|
||||
"generating": {
|
||||
"title": "영상 제작 중",
|
||||
"desc": "AI가 영상을 만들고 있어요. \n잠시만 기다려 주세요."
|
||||
},
|
||||
"completion": {
|
||||
"title": "영상 완성!",
|
||||
"desc": "영상 제작이 완료되었어요. \n영상을 확인해 볼까요?"
|
||||
},
|
||||
"myInfo": {
|
||||
"title": "소셜 계정 연동",
|
||||
"desc": "영상을 유튜브에 업로드하려면 내 정보에서 소셜 계정을 연동해야 해요. \n클릭해서 이동하세요."
|
||||
}
|
||||
},
|
||||
"myInfo": {
|
||||
"myInfo": {
|
||||
"title": "내 정보",
|
||||
"desc": "내 정보에서는 소셜 연결과 연결된 계정을 확인 할 수 있어요."
|
||||
},
|
||||
"connect": {
|
||||
"title": "연결하기",
|
||||
"desc": "YouTube 연결 버튼을 누르면 연결 페이지로 이동합니다.",
|
||||
"note": "Instagram 연결은 오픈 예정입니다."
|
||||
},
|
||||
"connected": {
|
||||
"title": "연결 계정",
|
||||
"desc": "연결된 소셜 계정 목록이에요. \n연결 후 여기서 확인할 수 있어요."
|
||||
},
|
||||
"ado2": {
|
||||
"title": "ADO2 콘텐츠",
|
||||
"desc": "이제 생성된 영상을 업로드할 수 있어요. \n클릭해서 이동하세요."
|
||||
}
|
||||
},
|
||||
"ado2": {
|
||||
"list": {
|
||||
"title": "생성된 영상 목록",
|
||||
"desc": "ADO2에서 만든 영상들을 확인할 수 있어요."
|
||||
},
|
||||
"download": {
|
||||
"title": "다운로드",
|
||||
"desc": "영상을 다운로드 할 수 있어요."
|
||||
},
|
||||
"delete": {
|
||||
"title": "삭제",
|
||||
"desc": "필요없는 영상을 삭제할 수 있어요."
|
||||
},
|
||||
"upload": {
|
||||
"title": "소셜 업로드",
|
||||
"desc": "선택해서 소셜미디어에 업로드하세요."
|
||||
}
|
||||
},
|
||||
"upload": {
|
||||
"seo": {
|
||||
"title": "제목 및 설명",
|
||||
"desc": "영상의 제목과 설명을 AI가 만들고 있어요. 잠시만 기다려 주세요."
|
||||
},
|
||||
"required": {
|
||||
"title": "필수 항목",
|
||||
"desc": "영상을 업로드 하기 전 *는 필수 항목으로 \n반드시 확인해 주세요."
|
||||
},
|
||||
"schedule": {
|
||||
"title": "업로드 예약",
|
||||
"desc": "지금 게시하거나 원하는 시간에 예약할 수 있어요."
|
||||
},
|
||||
"submit": {
|
||||
"title": "업로드 시작",
|
||||
"desc": "게시 버튼을 눌러 업로드를 시작하세요."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"metrics": {
|
||||
"title": "핵심 지표",
|
||||
"desc": "조회수, 구독자 등 ADO2로 업로드한 콘텐츠의 주요 통계를 확인하세요."
|
||||
},
|
||||
"chart": {
|
||||
"title": "성장 추이 차트",
|
||||
"desc": "기간별 성장 추이를 그래프로 확인할 수 있어요."
|
||||
},
|
||||
"more": {
|
||||
"title": "더 많은 통계",
|
||||
"desc": "그 외에도 다양한 통계를 대시보드에서 한눈에 확인할 수 있어요."
|
||||
}
|
||||
},
|
||||
"contentCalendar": {
|
||||
"grid": {
|
||||
"title": "콘텐츠 캘린더",
|
||||
"desc": "날짜별로 콘텐츠 스케줄을 확인할 수 있어요. \n오늘 날짜를 선택해 볼까요?"
|
||||
},
|
||||
"panel": {
|
||||
"title": "콘텐츠 목록",
|
||||
"desc": "자세한 콘텐츠 스케줄을 확인 할수 있어요."
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"complete": {
|
||||
"title": "튜토리얼 완료 🎉",
|
||||
"desc": "유튜브 업로드까지 모든 과정을 완료했어요. \n튜토리얼을 다시보고 싶다면 우측 상단의 버튼을 눌러주세요."
|
||||
},
|
||||
"title": "고객의견",
|
||||
"desc": "서비스 이용 중 불편한 점이나 개선 의견을 보내주세요."
|
||||
},
|
||||
"restart": {
|
||||
"title": "튜토리얼을 다시 시작할까요?",
|
||||
"desc": "현재 화면부터 튜토리얼이 다시 시작됩니다.",
|
||||
"confirm": "시작하기",
|
||||
"cancel": "취소"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"company": "㈜에이아이오투오",
|
||||
"businessNumber": "사업자 등록번호 : 620-87-00810 | 대표 : 안성민",
|
||||
|
||||
@ -2,9 +2,6 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AutocompleteRequest, isLoggedIn } from '../../utils/api';
|
||||
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
||||
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
||||
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
|
||||
import BusinessNameInputModal from '../../components/BusinessNameInputModal';
|
||||
import LoginPromptModal from '../../components/LoginPromptModal';
|
||||
import SearchInputForm from '../../components/SearchInputForm';
|
||||
@ -47,17 +44,6 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
||||
const [isLoginPromptOpen, setIsLoginPromptOpen] = useState(false);
|
||||
const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const animationRefs = useRef<number[]>([]);
|
||||
const tutorial = useTutorial();
|
||||
|
||||
// 첫 방문 시 랜딩 튜토리얼 시작
|
||||
useEffect(() => {
|
||||
if (!tutorial.hasSeen(TUTORIAL_KEYS.LANDING)) {
|
||||
const timer = setTimeout(() => {
|
||||
tutorial.startTutorial(TUTORIAL_KEYS.LANDING);
|
||||
}, 800);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Orb 랜덤 이동 애니메이션
|
||||
useEffect(() => {
|
||||
@ -170,22 +156,10 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{tutorial.isActive && !isManualModalOpen && (
|
||||
<TutorialOverlay
|
||||
hints={tutorial.hints}
|
||||
currentIndex={tutorial.currentHintIndex}
|
||||
onNext={tutorial.nextHint}
|
||||
onPrev={tutorial.prevHint}
|
||||
onSkip={tutorial.skipTutorial}
|
||||
groupProgress={tutorial.groupProgress}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isManualModalOpen && (
|
||||
<BusinessNameInputModal
|
||||
onClose={() => setIsManualModalOpen(false)}
|
||||
onSubmit={(businessName, address, category) => {
|
||||
if (tutorial.isActive) tutorial.nextHint();
|
||||
setIsManualModalOpen(false);
|
||||
onManualInput?.(businessName, address, category);
|
||||
}}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user