From 5407812889e6c83bd3ce41b482685da55485595c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Tue, 7 Apr 2026 14:54:43 +0900 Subject: [PATCH] =?UTF-8?q?=ED=8A=9C=ED=86=A0=EB=A6=AC=EC=96=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.css | 206 ++++++++++++++++ src/components/Sidebar.tsx | 5 +- src/components/SocialPostingModal.tsx | 23 ++ src/components/Tutorial/TutorialOverlay.tsx | 217 ++++++++++++++++ src/components/Tutorial/tutorialSteps.ts | 247 +++++++++++++++++++ src/components/Tutorial/useTutorial.ts | 115 +++++++++ src/locales/en.json | 62 ++++- src/locales/ko.json | 62 ++++- src/pages/Analysis/AnalysisResultSection.tsx | 28 ++- src/pages/Dashboard/CompletionContent.tsx | 22 ++ src/pages/Dashboard/GenerationFlow.tsx | 76 ++++++ src/pages/Landing/HeroSection.tsx | 24 ++ 12 files changed, 1083 insertions(+), 4 deletions(-) create mode 100644 src/components/Tutorial/TutorialOverlay.tsx create mode 100644 src/components/Tutorial/tutorialSteps.ts create mode 100644 src/components/Tutorial/useTutorial.ts diff --git a/index.css b/index.css index 48d8bd8..2ef0045 100644 --- a/index.css +++ b/index.css @@ -10551,3 +10551,209 @@ scrollbar-width: thin; scrollbar-color: #067C80 transparent; } + +/* ===================================================== + Tutorial Overlay + ===================================================== */ + +.tutorial-overlay-root { + position: fixed; + inset: 0; + z-index: 10000; + pointer-events: none; +} + +.tutorial-overlay-svg { + position: fixed; + inset: 0; + width: 100%; + height: 100%; + pointer-events: all; + cursor: default; +} + +.tutorial-tooltip { + position: fixed; + width: 300px; + background: #1a2630; + border: 1px solid rgba(166, 255, 234, 0.25); + border-radius: 12px; + padding: 16px; + pointer-events: all; + z-index: 10002; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} + +.tutorial-tooltip-title { + font-size: 15px; + font-weight: 700; + color: #a6ffea; + margin: 0 0 8px; +} + +.tutorial-tooltip-desc { + font-size: 13px; + color: rgba(255, 255, 255, 0.75); + margin: 0 0 14px; + line-height: 1.5; +} + +.tutorial-tooltip-footer { + display: flex; + align-items: center; + justify-content: space-between; +} + +.tutorial-tooltip-counter { + font-size: 12px; + color: rgba(255, 255, 255, 0.4); +} + +.tutorial-tooltip-actions { + display: flex; + gap: 6px; +} + +.tutorial-btn-skip { + font-size: 12px; + color: rgba(255, 255, 255, 0.4); + background: none; + border: none; + cursor: pointer; + padding: 4px 8px; +} + +.tutorial-btn-skip:hover { + color: rgba(255, 255, 255, 0.7); +} + +.tutorial-btn-prev { + font-size: 13px; + color: rgba(255, 255, 255, 0.7); + background: rgba(255, 255, 255, 0.08); + border: none; + border-radius: 6px; + cursor: pointer; + padding: 5px 12px; +} + +.tutorial-btn-prev:hover { + background: rgba(255, 255, 255, 0.14); +} + +.tutorial-btn-next { + font-size: 13px; + color: #fff; + background: #6366f1; + border: none; + border-radius: 6px; + cursor: pointer; + padding: 5px 12px; +} + +.tutorial-btn-next:hover { + background: #4f46e5; +} + +.tutorial-tooltip-action-hint { + font-size: 11px; + color: #a6ffea; + margin: 8px 0 0; + opacity: 0.8; +} + +/* 튜토리얼 재시작 팝업 */ +.tutorial-restart-backdrop { + position: fixed; + inset: 0; + z-index: 10100; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.5); +} + +.tutorial-restart-popup { + background: #1a2630; + border: 1px solid rgba(166, 255, 234, 0.25); + border-radius: 14px; + padding: 24px 28px; + width: 320px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6); +} + +.tutorial-restart-title { + font-size: 16px; + font-weight: 700; + color: #a6ffea; + margin: 0 0 10px; +} + +.tutorial-restart-desc { + font-size: 13px; + color: rgba(255, 255, 255, 0.65); + margin: 0 0 20px; + line-height: 1.5; +} + +.tutorial-restart-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.tutorial-restart-cancel { + font-size: 13px; + color: rgba(255, 255, 255, 0.5); + background: rgba(255, 255, 255, 0.08); + border: none; + border-radius: 8px; + cursor: pointer; + padding: 7px 16px; +} + +.tutorial-restart-cancel:hover { + background: rgba(255, 255, 255, 0.14); + color: rgba(255, 255, 255, 0.8); +} + +.tutorial-restart-confirm { + font-size: 13px; + font-weight: 600; + color: #0d1a1f; + background: #a6ffea; + border: none; + border-radius: 8px; + cursor: pointer; + padding: 7px 18px; +} + +.tutorial-restart-confirm:hover { + background: #c0fff2; +} + +/* 사이드바 튜토리얼 버튼 */ +.tutorial-restart-fab { + position: fixed; + top: 16px; + right: 20px; + z-index: 900; + display: flex; + align-items: center; + gap: 8px; + padding: 7px 14px; + background: rgba(0, 34, 36, 0.85); + border: 1px solid rgba(166, 255, 234, 0.25); + border-radius: 20px; + color: rgba(255, 255, 255, 0.65); + cursor: pointer; + font-size: 13px; + backdrop-filter: blur(6px); + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.tutorial-restart-fab:hover { + background: rgba(166, 255, 234, 0.12); + border-color: rgba(166, 255, 234, 0.5); + color: #a6ffea; +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 9533886..bdbf1a6 100755 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -12,11 +12,13 @@ interface SidebarItemProps { isCollapsed: boolean; isDisabled?: boolean; onClick?: () => void; + id?: string; } -const SidebarItem: React.FC = ({ icon, label, isActive, isCollapsed, isDisabled, onClick }) => { +const SidebarItem: React.FC = ({ icon, label, isActive, isCollapsed, isDisabled, onClick, id }) => { return (
= ({ activeItem, onNavigate, onHome, userI {menuItems.map(item => ( = ({ video }) => { const { t } = useTranslation(); + const tutorial = useTutorial(); const [socialAccounts, setSocialAccounts] = useState([]); const [selectedChannel, setSelectedChannel] = useState(''); const [title, setTitle] = useState(''); @@ -171,6 +175,16 @@ const SocialPostingModal: React.FC = ({ return () => { document.body.style.overflow = ''; }; }, [isOpen]); + // 모달 최초 오픈 시 튜토리얼 트리거 + useEffect(() => { + if (isOpen && !tutorial.hasSeen(TUTORIAL_KEYS.UPLOAD_MODAL)) { + const timer = setTimeout(() => { + tutorial.startTutorial(TUTORIAL_KEYS.UPLOAD_MODAL); + }, 400); + return () => clearTimeout(timer); + } + }, [isOpen]); + // 소셜 계정 로드 useEffect(() => { if (isOpen) { @@ -761,6 +775,15 @@ const SocialPostingModal: React.FC = ({
{uploadProgressModalElement} + {tutorial.isActive && ( + + )} ); }; diff --git a/src/components/Tutorial/TutorialOverlay.tsx b/src/components/Tutorial/TutorialOverlay.tsx new file mode 100644 index 0000000..1c9e267 --- /dev/null +++ b/src/components/Tutorial/TutorialOverlay.tsx @@ -0,0 +1,217 @@ +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; +} + +interface TooltipPos { + top: number; + left: number; +} + +interface TutorialOverlayProps { + hints: TutorialHint[]; + currentIndex: number; + onNext: () => void; + onPrev: () => void; + onSkip: () => void; +} + +const PADDING = 8; + +function getTargetRect(selector: string): Rect | null { + const el = document.querySelector(selector); + if (!el) return null; + const r = el.getBoundingClientRect(); + return { top: r.top, left: r.left, width: r.width, height: r.height }; +} + +function calcTooltipPos(rect: Rect, position: TutorialHint['position']): TooltipPos { + const tooltipW = 300; + const tooltipH = 140; + + 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 + ), + }; + 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 + ), + }; + case 'right': + return { + top: Math.max(rect.top + rect.height / 2 - tooltipH / 2, 8), + left: rect.left + rect.width + PADDING, + }; + case 'left': + return { + top: Math.max(rect.top + rect.height / 2 - tooltipH / 2, 8), + left: rect.left - tooltipW - PADDING, + }; + } +} + +const TutorialOverlay: React.FC = ({ + hints, + currentIndex, + onNext, + onPrev, + onSkip, +}) => { + const { t } = useTranslation(); + const [targetRect, setTargetRect] = useState(null); + + const hint = hints[currentIndex]; + const isLast = currentIndex === hints.length - 1; + + const updateRect = useCallback(() => { + if (!hint) return; + setTargetRect(getTargetRect(hint.targetSelector)); + }, [hint]); + + useEffect(() => { + updateRect(); + window.addEventListener('resize', updateRect); + return () => window.removeEventListener('resize', updateRect); + }, [updateRect]); + + // 대상 요소 z-index 끌어올리기 + 스크롤 + 클릭 시 onNext 연결 + useEffect(() => { + if (!hint) return; + const el = document.querySelector(hint.targetSelector); + if (!el) return; + + // 스크롤해서 요소가 화면 중앙에 오도록 + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + + // scrollIntoView 완료 후 rect 재계산 + const timer = setTimeout(updateRect, 400); + + 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); + } + + return () => { + clearTimeout(timer); + el.style.zIndex = prevZ; + el.style.position = prevPos; + el.style.cursor = prevCursor; + if (shouldClickAdvance) { + el.removeEventListener('click', onNext); + } + }; + }, [hint, updateRect, onNext]); + + if (!hint) return null; + + // 대상 요소가 없으면 화면 중앙에 툴팁 표시 (오버레이는 보이지 않음) + const tooltipPos: TooltipPos = targetRect + ? calcTooltipPos(targetRect, hint.position) + : { + top: window.innerHeight / 2 - 70, + 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; + + return ( +
+ {/* 어두운 배경 + 스포트라이트 — 배경 클릭 시 아무 동작 없음 */} + {spotlight && ( + + + + )} + + {/* 툴팁 — 항상 표시 */} +
e.stopPropagation()} + > +

{t(hint.titleKey)}

+

{t(hint.descriptionKey)}

+ +
+ + {currentIndex + 1} / {hints.length} + +
+ + {currentIndex > 0 && ( + + )} + +
+
+
+
+ ); +}; + +export default TutorialOverlay; + +interface TutorialRestartPopupProps { + onConfirm: () => void; + onCancel: () => void; +} + +export const TutorialRestartPopup: React.FC = ({ onConfirm, onCancel }) => { + const { t } = useTranslation(); + return ( +
+
e.stopPropagation()}> +

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

+

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

+
+ + +
+
+
+ ); +}; diff --git a/src/components/Tutorial/tutorialSteps.ts b/src/components/Tutorial/tutorialSteps.ts new file mode 100644 index 0000000..da29c12 --- /dev/null +++ b/src/components/Tutorial/tutorialSteps.ts @@ -0,0 +1,247 @@ +export interface TutorialHint { + targetSelector: string; + titleKey: string; + descriptionKey: string; + position: 'top' | 'bottom' | 'left' | 'right'; + clickToAdvance?: boolean; // true면 요소 클릭 시 튜토리얼 진행, false면 툴팁 버튼으로만 진행 +} + +export interface TutorialStepDef { + key: string; + hints: TutorialHint[]; +} + +export const TUTORIAL_KEYS = { + LANDING: 'landing', + ANALYSIS: 'analysis', + ASSET: 'asset', + SOUND: 'sound', + COMPLETION: 'completion', + MY_INFO: 'myInfo', + ADO2_CONTENTS: 'ado2Contents', + UPLOAD_MODAL: 'uploadModal', + DASHBOARD: 'dashboard', + FEEDBACK: 'feedback', +} as const; + +export const tutorialSteps: TutorialStepDef[] = [ + { + key: TUTORIAL_KEYS.LANDING, + hints: [ + { + targetSelector: '.hero-dropdown-container', + titleKey: 'tutorial.landing.dropdown.title', + descriptionKey: 'tutorial.landing.dropdown.desc', + position: 'bottom', + }, + { + targetSelector: '.hero-input-container', + titleKey: 'tutorial.landing.field.title', + descriptionKey: 'tutorial.landing.field.desc', + position: 'bottom', + }, + { + targetSelector: '.hero-button', + titleKey: 'tutorial.landing.button.title', + descriptionKey: 'tutorial.landing.button.desc', + position: 'top', + }, + ], + }, + { + key: TUTORIAL_KEYS.ANALYSIS, + hints: [ + { + targetSelector: '.bi2-identity-card', + titleKey: 'tutorial.analysis.identity.title', + descriptionKey: 'tutorial.analysis.identity.desc', + position: 'bottom', + clickToAdvance: false, + }, + { + targetSelector: '.bi2-selling-card', + titleKey: 'tutorial.analysis.selling.title', + descriptionKey: 'tutorial.analysis.selling.desc', + position: 'bottom', + clickToAdvance: false, + }, + { + targetSelector: '.bi2-persona-grid', + titleKey: 'tutorial.analysis.persona.title', + descriptionKey: 'tutorial.analysis.persona.desc', + position: 'bottom', + clickToAdvance: false, + }, + { + targetSelector: '.bi2-keyword-tags', + titleKey: 'tutorial.analysis.keywords.title', + descriptionKey: 'tutorial.analysis.keywords.desc', + position: 'bottom', + clickToAdvance: false, + }, + { + targetSelector: '.bi2-generate-btn', + titleKey: 'tutorial.analysis.generate.title', + descriptionKey: 'tutorial.analysis.generate.desc', + position: 'top', + clickToAdvance: true, + }, + ], + }, + { + key: TUTORIAL_KEYS.ASSET, + hints: [ + { + targetSelector: '.asset-upload-zone', + titleKey: 'tutorial.asset.upload.title', + descriptionKey: 'tutorial.asset.upload.desc', + position: 'left', + }, + { + targetSelector: '.asset-ratio-section', + titleKey: 'tutorial.asset.ratio.title', + descriptionKey: 'tutorial.asset.ratio.desc', + position: 'left', + }, + { + targetSelector: '.asset-next-button', + titleKey: 'tutorial.asset.next.title', + descriptionKey: 'tutorial.asset.next.desc', + position: 'top', + }, + ], + }, + { + key: TUTORIAL_KEYS.SOUND, + hints: [ + { + targetSelector: '.genre-grid', + titleKey: 'tutorial.sound.genre.title', + descriptionKey: 'tutorial.sound.genre.desc', + position: 'right', + clickToAdvance: false, + }, + { + targetSelector: '.language-selector-wrapper', + titleKey: 'tutorial.sound.language.title', + descriptionKey: 'tutorial.sound.language.desc', + position: 'right', + clickToAdvance: false, + }, + { + targetSelector: '.btn-generate-sound', + titleKey: 'tutorial.sound.generate.title', + descriptionKey: 'tutorial.sound.generate.desc', + position: 'top', + clickToAdvance: true, + }, + { + 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.connect.title', + descriptionKey: 'tutorial.myInfo.connect.desc', + position: 'bottom', + }, + { + targetSelector: '.youtube-connect-button', + titleKey: 'tutorial.myInfo.button.title', + descriptionKey: 'tutorial.myInfo.button.desc', + position: 'top', + }, + ], + }, + { + key: TUTORIAL_KEYS.ADO2_CONTENTS, + hints: [ + { + targetSelector: '.ado2-contents-list', + titleKey: 'tutorial.ado2.list.title', + descriptionKey: 'tutorial.ado2.list.desc', + position: 'bottom', + }, + { + targetSelector: '.ado2-upload-button', + titleKey: 'tutorial.ado2.upload.title', + descriptionKey: 'tutorial.ado2.upload.desc', + position: 'top', + }, + ], + }, + { + key: TUTORIAL_KEYS.COMPLETION, + hints: [ + { + 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-field', + titleKey: 'tutorial.upload.title.title', + descriptionKey: 'tutorial.upload.title.desc', + position: 'left', + clickToAdvance: false, + }, + { + targetSelector: '.social-posting-radio-group', + titleKey: 'tutorial.upload.schedule.title', + descriptionKey: 'tutorial.upload.schedule.desc', + position: 'left', + 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: '.dashboard-metrics-row', + titleKey: 'tutorial.dashboard.metrics.title', + descriptionKey: 'tutorial.dashboard.metrics.desc', + position: 'bottom', + }, + { + targetSelector: '.dashboard-chart-section', + titleKey: 'tutorial.dashboard.chart.title', + descriptionKey: 'tutorial.dashboard.chart.desc', + position: 'top', + }, + ], + }, + { + key: TUTORIAL_KEYS.FEEDBACK, + hints: [ + { + targetSelector: '.sidebar-inquiry-btn', + titleKey: 'tutorial.feedback.title', + descriptionKey: 'tutorial.feedback.desc', + position: 'right', + }, + ], + }, +]; diff --git a/src/components/Tutorial/useTutorial.ts b/src/components/Tutorial/useTutorial.ts new file mode 100644 index 0000000..9b92a7a --- /dev/null +++ b/src/components/Tutorial/useTutorial.ts @@ -0,0 +1,115 @@ +import { useState, useCallback } from 'react'; +import { tutorialSteps, TutorialHint } from './tutorialSteps'; + +const SEEN_KEY = 'castad_tutorial_seen'; + +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])); + } +} + +interface UseTutorialReturn { + isActive: boolean; + isRestartPopupVisible: boolean; + currentHintIndex: number; + hints: TutorialHint[]; + tutorialKey: string | null; + startTutorial: (key: string) => void; + nextHint: () => void; + prevHint: () => void; + skipTutorial: () => void; + showRestartPopup: (key: string) => void; + confirmRestart: () => void; + cancelRestart: () => void; + hasSeen: (key: string) => boolean; +} + +export function useTutorial(): UseTutorialReturn { + const [isActive, setIsActive] = useState(false); + const [isRestartPopupVisible, setIsRestartPopupVisible] = useState(false); + const [pendingRestartKey, setPendingRestartKey] = useState(null); + const [currentHintIndex, setCurrentHintIndex] = useState(0); + const [hints, setHints] = useState([]); + const [tutorialKey, setTutorialKey] = useState(null); + + const startTutorial = useCallback((key: string) => { + const step = tutorialSteps.find(s => s.key === key); + if (!step || step.hints.length === 0) return; + setHints(step.hints); + setTutorialKey(key); + setCurrentHintIndex(0); + setIsActive(true); + }, []); + + const nextHint = useCallback(() => { + setCurrentHintIndex(prev => { + if (prev < hints.length - 1) return prev + 1; + // 마지막 힌트 완료 → seen 기록 + setIsActive(false); + if (tutorialKey) markSeen(tutorialKey); + return 0; + }); + }, [hints.length, tutorialKey]); + + const prevHint = useCallback(() => { + setCurrentHintIndex(prev => Math.max(0, prev - 1)); + }, []); + + // 건너뛰기: 오버레이만 닫고 seen 기록 안 함 → 다음 방문 시 다시 표시 + const skipTutorial = useCallback(() => { + setIsActive(false); + setCurrentHintIndex(0); + }, []); + + // 튜토리얼 다시 보기: 팝업 표시만 + const showRestartPopup = useCallback((key: string) => { + setPendingRestartKey(key); + setIsRestartPopupVisible(true); + }, []); + + // 팝업에서 확인 → seen 초기화 후 튜토리얼 시작 + const confirmRestart = useCallback(() => { + setIsRestartPopupVisible(false); + if (pendingRestartKey) { + localStorage.removeItem(SEEN_KEY); + startTutorial(pendingRestartKey); + } + setPendingRestartKey(null); + }, [pendingRestartKey, startTutorial]); + + // 팝업에서 취소 + const cancelRestart = useCallback(() => { + setIsRestartPopupVisible(false); + setPendingRestartKey(null); + }, []); + + const hasSeen = useCallback((key: string) => { + return getSeenKeys().includes(key); + }, []); + + return { + isActive, + isRestartPopupVisible, + currentHintIndex, + hints, + tutorialKey, + startTutorial, + nextHint, + prevHint, + skipTutorial, + showRestartPopup, + confirmRestart, + cancelRestart, + hasSeen, + }; +} diff --git a/src/locales/en.json b/src/locales/en.json index a941afd..0a164e4 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -13,7 +13,67 @@ "myInfo": "My Info", "defaultUser": "User", "loggingOut": "Logging out...", - "logout": "Log Out" + "logout": "Log Out", + "tutorialRestart": "Restart Tutorial" + }, + "tutorial": { + "skip": "Skip", + "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." }, + "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." }, + "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." } + }, + "asset": { + "upload": { "title": "Add Images", "desc": "Upload or remove brand images." }, + "ratio": { "title": "Select Video Ratio", "desc": "Choose the best ratio for YouTube." }, + "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." } + }, + "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." } + }, + "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." } + }, + "completion": { + "upload": { "title": "Upload to YouTube", "desc": "Click the button to upload your video to YouTube." }, + "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." } + }, + "dashboard": { + "metrics": { "title": "Key Metrics", "desc": "Check views, subscribers, and other stats." }, + "chart": { "title": "Growth Chart", "desc": "Track your channel's growth over time." } + }, + "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.", + "confirm": "Start", + "cancel": "Cancel" + } }, "footer": { "company":"O2O Inc.", diff --git a/src/locales/ko.json b/src/locales/ko.json index 8661087..670f564 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -13,7 +13,67 @@ "myInfo": "내 정보", "defaultUser": "사용자", "loggingOut": "로그아웃 중...", - "logout": "로그아웃" + "logout": "로그아웃", + "tutorialRestart": "튜토리얼 다시 보기" + }, + "tutorial": { + "skip": "건너뛰기", + "next": "다음", + "prev": "이전", + "finish": "완료", + "clickToProceed": "위 항목을 직접 선택하면 다음으로 넘어갑니다.", + "clickToFinish": "위 항목을 직접 선택하면 튜토리얼이 완료됩니다.", + "landing": { + "dropdown": { "title": "검색 방식 선택", "desc": "URL 또는 업체명 중 원하는 방식을 선택하세요." }, + "field": { "title": "URL 또는 업체명 입력", "desc": "네이버 지도에서 장소를 검색하고 공유 클릭하여 나온 URL을 붙여넣거나 업체명을 입력하고 선택하세요." }, + "button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." } + }, + "analysis": { + "identity": { "title": "브랜드 정체성", "desc": "AI가 분석한 브랜드의 핵심 가치와 시장 포지셔닝을 확인하세요." }, + "selling": { "title": "주요 셀링 포인트", "desc": "브랜드의 강점을 확인할 수 있어요." }, + "persona": { "title": "주요 고객 유형", "desc": "어떤 고객이 이 브랜드를 찾는지 분석했어요." }, + "keywords": { "title": "추천 타겟 키워드", "desc": "고객이 검색할 가능성이 높은 키워드들이에요." }, + "generate": { "title": "콘텐츠 생성", "desc": "분석 결과를 바탕으로 영상 콘텐츠를 만들어 보세요. 버튼을 클릭하면 카카오 로그인으로 이동합니다." } + }, + "asset": { + "upload": { "title": "이미지 추가", "desc": "브랜드 이미지를 추가하거나 삭제할 수 있어요." }, + "ratio": { "title": "영상 비율 선택", "desc": "유튜브에 최적화된 비율을 선택하세요." }, + "next": { "title": "다음 단계로", "desc": "설정이 완료되면 다음 단계로 진행하세요." } + }, + "sound": { + "genre": { "title": "장르 선택", "desc": "영상에 어울리는 음악 장르를 선택하세요." }, + "language": { "title": "언어 선택", "desc": "사운드의 언어를 선택하세요." }, + "generate": { "title": "사운드 생성", "desc": "AI가 브랜드에 맞는 음악을 생성해요." }, + "video": { "title": "영상 생성", "desc": "사운드 생성 후 영상을 만들 수 있어요." } + }, + "myInfo": { + "connect": { "title": "소셜 연동", "desc": "영상을 업로드하려면 소셜 계정을 연동해야 해요." }, + "button": { "title": "연동하기", "desc": "원하는 소셜미디어 버튼을 클릭해서 연동하세요." } + }, + "ado2": { + "list": { "title": "생성된 영상 목록", "desc": "ADO2에서 만든 영상들을 여기서 확인할 수 있어요." }, + "upload": { "title": "소셜 업로드", "desc": "원하는 영상을 선택해서 소셜미디어에 업로드하세요." } + }, + "completion": { + "upload": { "title": "업로드", "desc": "버튼을 눌러 완성된 영상을 업로드하세요." }, + "myInfo": { "title": "소셜 계정 연동", "desc": "영상을 유튜브에 업로드하려면 내 정보에서 소셜 계정을 연동해야 해요. 클릭해서 이동하세요." } + }, + "upload": { + "title": { "title": "제목 및 설명 입력", "desc": "유튜브에 표시될 제목과 설명을 입력하세요." }, + "schedule": { "title": "업로드 예약", "desc": "즉시 업로드하거나 원하는 날짜에 예약할 수 있어요." }, + "submit": { "title": "업로드 시작", "desc": "버튼을 눌러 유튜브 업로드를 시작하세요." } + }, + "dashboard": { + "metrics": { "title": "핵심 지표", "desc": "조회수, 구독자 등 채널의 주요 통계를 확인하세요." }, + "chart": { "title": "성장 추이 차트", "desc": "기간별 성장 추이를 그래프로 확인할 수 있어요." } + }, + "feedback": { "title": "고객의견", "desc": "서비스 이용 중 불편한 점이나 개선 의견을 보내주세요." }, + "restart": { + "title": "튜토리얼을 다시 시작할까요?", + "desc": "현재 화면부터 튜토리얼이 다시 시작됩니다.", + "confirm": "시작하기", + "cancel": "취소" + } }, "footer": { "company":"㈜에이아이오투오", diff --git a/src/pages/Analysis/AnalysisResultSection.tsx b/src/pages/Analysis/AnalysisResultSection.tsx index 88a49f7..2988c4d 100755 --- a/src/pages/Analysis/AnalysisResultSection.tsx +++ b/src/pages/Analysis/AnalysisResultSection.tsx @@ -1,8 +1,11 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { CrawlingResponse, TargetPersona } from '../../types/api'; import { GeometricChart } from './GeometricChart'; +import { useTutorial } from '../../components/Tutorial/useTutorial'; +import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps'; +import TutorialOverlay from '../../components/Tutorial/TutorialOverlay'; interface AnalysisResultSectionProps { onBack: () => void; @@ -12,8 +15,19 @@ interface AnalysisResultSectionProps { const AnalysisResultSection: React.FC = ({ onBack, onGenerate, data }) => { const { t } = useTranslation(); + const tutorial = useTutorial(); const { processed_info, marketing_analysis } = data; + useEffect(() => { + const timer = setTimeout(() => { + if (!tutorial.hasSeen(TUTORIAL_KEYS.ANALYSIS)) { + tutorial.startTutorial(TUTORIAL_KEYS.ANALYSIS); + } + }, 600); + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const brandIdentity = marketing_analysis?.brand_identity; const marketPositioning = marketing_analysis?.market_positioning; const targetPersonas = marketing_analysis?.target_persona || []; @@ -24,6 +38,7 @@ const AnalysisResultSection: React.FC = ({ onBack, o const sortedSellingPoints = [...sellingPoints].sort((a, b) => b.score - a.score); return ( + <>
{/* Header */}
@@ -175,6 +190,17 @@ const AnalysisResultSection: React.FC = ({ onBack, o
+ + {tutorial.isActive && ( + + )} + ); }; diff --git a/src/pages/Dashboard/CompletionContent.tsx b/src/pages/Dashboard/CompletionContent.tsx index 4da046e..8c5c141 100755 --- a/src/pages/Dashboard/CompletionContent.tsx +++ b/src/pages/Dashboard/CompletionContent.tsx @@ -3,6 +3,9 @@ import React, { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { generateVideo, waitForVideoComplete } from '../../utils/api'; import SocialPostingModal from '../../components/SocialPostingModal'; +import { useTutorial } from '../../components/Tutorial/useTutorial'; +import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps'; +import TutorialOverlay from '../../components/Tutorial/TutorialOverlay'; interface CompletionContentProps { onBack: () => void; @@ -39,6 +42,15 @@ const CompletionContent: React.FC = ({ const [renderProgress, setRenderProgress] = useState(0); const hasStartedGeneration = useRef(false); + const tutorial = useTutorial(); + + // 영상 완료 시 튜토리얼 트리거 + useEffect(() => { + if (videoStatus === 'complete' && !tutorial.hasSeen(TUTORIAL_KEYS.COMPLETION)) { + tutorial.startTutorial(TUTORIAL_KEYS.COMPLETION); + } + }, [videoStatus]); + // 소셜 미디어 포스팅 모달 const [showSocialModal, setShowSocialModal] = useState(false); const [videoDbId, setVideoDbId] = useState(null); @@ -438,6 +450,16 @@ const CompletionContent: React.FC = ({ created_at: new Date().toISOString(), } : null} /> + + {tutorial.isActive && ( + + )} ); }; diff --git a/src/pages/Dashboard/GenerationFlow.tsx b/src/pages/Dashboard/GenerationFlow.tsx index f39633c..a0428d1 100755 --- a/src/pages/Dashboard/GenerationFlow.tsx +++ b/src/pages/Dashboard/GenerationFlow.tsx @@ -15,6 +15,9 @@ import LoadingSection from '../Analysis/LoadingSection'; import AnalysisResultSection from '../Analysis/AnalysisResultSection'; import { ImageItem, CrawlingResponse, UserMeResponse } from '../../types/api'; import { crawlUrl, autocomplete, AutocompleteRequest, getUserMe, clearTokens } from '../../utils/api'; +import { useTutorial } from '../../components/Tutorial/useTutorial'; +import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps'; +import TutorialOverlay, { TutorialRestartPopup } from '../../components/Tutorial/TutorialOverlay'; const WIZARD_STEP_KEY = 'castad_wizard_step'; const ACTIVE_ITEM_KEY = 'castad_active_item'; @@ -112,6 +115,7 @@ const GenerationFlow: React.FC = ({ const [analysisData, setAnalysisData] = useState(parseAnalysisData()); const [analysisError, setAnalysisError] = useState(null); const [userInfo, setUserInfo] = useState(null); + const tutorial = useTutorial(); // 로그인 직후 사용자 정보 조회 useEffect(() => { @@ -316,6 +320,29 @@ const GenerationFlow: React.FC = ({ localStorage.setItem(ACTIVE_ITEM_KEY, activeItem); }, [activeItem]); + // wizardStep 변경 시 튜토리얼 트리거 + useEffect(() => { + const timer = setTimeout(() => { + if (wizardStep === 1 && !tutorial.hasSeen(TUTORIAL_KEYS.ASSET)) { + tutorial.startTutorial(TUTORIAL_KEYS.ASSET); + } else if (wizardStep === 2 && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND)) { + tutorial.startTutorial(TUTORIAL_KEYS.SOUND); + } + }, 600); + return () => clearTimeout(timer); + }, [wizardStep]); + + // activeItem 변경 시 튜토리얼 트리거 + useEffect(() => { + if (activeItem === '내 정보' && !tutorial.hasSeen(TUTORIAL_KEYS.MY_INFO)) { + tutorial.startTutorial(TUTORIAL_KEYS.MY_INFO); + } else if (activeItem === 'ADO2 콘텐츠' && !tutorial.hasSeen(TUTORIAL_KEYS.ADO2_CONTENTS)) { + tutorial.startTutorial(TUTORIAL_KEYS.ADO2_CONTENTS); + } else if (activeItem === '대시보드' && !tutorial.hasSeen(TUTORIAL_KEYS.DASHBOARD)) { + tutorial.startTutorial(TUTORIAL_KEYS.DASHBOARD); + } + }, [activeItem]); + // 네비게이션 핸들러 - "새 프로젝트 만들기" 클릭 시 기존 프로젝트 데이터 초기화 const handleNavigate = (item: string) => { if (item === '새 프로젝트 만들기') { @@ -466,6 +493,53 @@ const GenerationFlow: React.FC = ({ // 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0), ADO2 콘텐츠, 내 정보 const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || activeItem === 'ADO2 콘텐츠' || activeItem === '내 정보' || activeItem === '콘텐츠 캘린더' || isBrandAnalysis; + // 현재 화면에 맞는 튜토리얼 키 반환 + const getCurrentTutorialKey = (): string => { + if (activeItem === '내 정보') return TUTORIAL_KEYS.MY_INFO; + if (activeItem === 'ADO2 콘텐츠') return TUTORIAL_KEYS.ADO2_CONTENTS; + if (activeItem === '대시보드') return TUTORIAL_KEYS.DASHBOARD; + if (activeItem === '새 프로젝트 만들기') { + if (wizardStep === 1) return TUTORIAL_KEYS.ASSET; + if (wizardStep === 2) return TUTORIAL_KEYS.SOUND; + } + return TUTORIAL_KEYS.ASSET; + }; + + const handleTutorialRestart = () => { + tutorial.showRestartPopup(getCurrentTutorialKey()); + }; + + const tutorialUI = ( + <> + + {tutorial.isActive && ( + + )} + {tutorial.isRestartPopupVisible && ( + + )} + + ); + // 브랜드 분석일 때는 전체 화면 스크롤 if (isBrandAnalysis) { return ( @@ -476,6 +550,7 @@ const GenerationFlow: React.FC = ({
{renderContent()}
+ {tutorialUI} ); } @@ -485,6 +560,7 @@ const GenerationFlow: React.FC = ({ {showSidebar && ( )} + {tutorialUI}
{renderContent()}
diff --git a/src/pages/Landing/HeroSection.tsx b/src/pages/Landing/HeroSection.tsx index f90f281..ac53236 100755 --- a/src/pages/Landing/HeroSection.tsx +++ b/src/pages/Landing/HeroSection.tsx @@ -3,6 +3,9 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../../utils/api'; import { CrawlingResponse } from '../../types/api'; +import { useTutorial } from '../../components/Tutorial/useTutorial'; +import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps'; +import TutorialOverlay from '../../components/Tutorial/TutorialOverlay'; type SearchType = 'url' | 'name'; @@ -92,6 +95,17 @@ const HeroSection: React.FC = ({ onAnalyze, onAutocomplete, on const dropdownRef = useRef(null); const autocompleteRef = useRef(null); const debounceRef = useRef(null); + const tutorial = useTutorial(); + + // 첫 방문 시 랜딩 튜토리얼 시작 + useEffect(() => { + if (!tutorial.hasSeen(TUTORIAL_KEYS.LANDING)) { + const timer = setTimeout(() => { + tutorial.startTutorial(TUTORIAL_KEYS.LANDING); + }, 800); + return () => clearTimeout(timer); + } + }, []); const searchTypeOptions = [ { value: 'url' as SearchType, label: 'URL' }, @@ -502,6 +516,16 @@ const HeroSection: React.FC = ({ onAnalyze, onAutocomplete, on {isLoadingTest ? t('landing.hero.testDataLoading') : t('landing.hero.testData')} )} + + {tutorial.isActive && ( + + )} ); };