diff --git a/public/assets/images/ado2_image.png b/public/assets/images/ado2_image.png new file mode 100644 index 0000000..ed58869 Binary files /dev/null and b/public/assets/images/ado2_image.png differ diff --git a/src/App.tsx b/src/App.tsx index 17d2168..8d096af 100755 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,16 +1,28 @@ -import React, { useState, useEffect } from 'react'; +import React, { useRef, useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; +import Header from './components/Header'; +import HeroSection from './pages/Landing/HeroSection'; +import WelcomeSection from './pages/Landing/WelcomeSection'; +import DisplaySection from './pages/Landing/DisplaySection'; +import LoadingSection from './pages/Analysis/LoadingSection'; +import AnalysisResultSection from './pages/Analysis/AnalysisResultSection'; +import LoginSection from './pages/Login/LoginSection'; import GenerationFlow from './pages/Dashboard/GenerationFlow'; import SocialConnectSuccess from './pages/Social/SocialConnectSuccess'; import SocialConnectError from './pages/Social/SocialConnectError'; import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback'; +import ADO2ContentsPage from './pages/Dashboard/ADO2ContentsPage'; import VideoDetailPage from './components/VideoDetailPage'; import SsulDetailPage from './pages/Ssulbox/SsulDetailPage'; -import { kakaoCallback, saveTokens, storeUtmFromUrl, trackViewContent, trackCompleteRegistration } from './utils/api'; -import { clearSessionStorage } from './utils/storageKeys'; -import { NAV } from './components/navItems'; +import { crawlUrl, autocomplete, marketingAnalysis, kakaoCallback, isLoggedIn, saveTokens, getVideosList, AutocompleteRequest, storeUtmFromUrl, trackViewContent, trackCompleteRegistration } from './utils/api'; +import { saveSearchHistory } from './components/SearchHistory/useSearchHistory'; +import { CrawlingResponse } from './types/api'; +type ViewMode = 'landing' | 'loading' | 'analysis' | 'login' | 'generation_flow'; + +const VIEW_MODE_KEY = 'castad_view_mode'; +const ANALYSIS_DATA_KEY = 'castad_analysis_data'; const SESSION_KEY = 'castad_session_active'; // 새 탭/새 창에서 접근 시 localStorage 초기화 (sessionStorage로 현재 세션 확인) @@ -19,7 +31,15 @@ const initializeOnNewSession = () => { if (!isExistingSession) { // 새 세션이면 localStorage 정리하고 세션 표시 - clearSessionStorage(); + localStorage.removeItem(VIEW_MODE_KEY); + localStorage.removeItem(ANALYSIS_DATA_KEY); + localStorage.removeItem('castad_wizard_step'); + localStorage.removeItem('castad_active_item'); + localStorage.removeItem('castad_song_task_id'); + localStorage.removeItem('castad_image_task_id'); + localStorage.removeItem('castad_song_generation'); + localStorage.removeItem('castad_video_generation'); + localStorage.removeItem('castad_video_ratio'); sessionStorage.setItem(SESSION_KEY, 'true'); } }; @@ -28,9 +48,79 @@ const initializeOnNewSession = () => { initializeOnNewSession(); const App: React.FC = () => { - const { t } = useTranslation(); - const [initialTab, setInitialTab] = useState(NAV.HOME); + const { t, i18n } = useTranslation(); + const containerRef = useRef(null); + + // localStorage에서 저장된 상태 복원 (새 세션이면 이미 초기화됨) + const savedViewMode = localStorage.getItem(VIEW_MODE_KEY) as ViewMode | null; + const savedAnalysisData = localStorage.getItem(ANALYSIS_DATA_KEY); + + // 저장된 분석 데이터 파싱 및 유효성 검사 + const parseSavedAnalysisData = (): CrawlingResponse | null => { + if (!savedAnalysisData) return null; + try { + const data = JSON.parse(savedAnalysisData) as CrawlingResponse; + // 기본값 보장 + if (data.marketing_analysis) { + data.marketing_analysis.brand_identity = data.marketing_analysis.brand_identity || { + location_feature_analysis: '', + concept_scalability: '' + }; + data.marketing_analysis.market_positioning = data.marketing_analysis.market_positioning || { + category_definition: '', + core_value: '' + }; + data.marketing_analysis.target_persona = data.marketing_analysis.target_persona || []; + data.marketing_analysis.selling_points = data.marketing_analysis.selling_points || []; + data.marketing_analysis.target_keywords = data.marketing_analysis.target_keywords || []; + } + if (data.processed_info) { + data.processed_info.customer_name = data.processed_info.customer_name || '알 수 없음'; + data.processed_info.region = data.processed_info.region || ''; + data.processed_info.detail_region_info = data.processed_info.detail_region_info || ''; + } + data.image_list = data.image_list || []; + return data; + } catch { + localStorage.removeItem(ANALYSIS_DATA_KEY); + return null; + } + }; + + // 초기 viewMode 결정: 로그인 상태면 바로 generation_flow로 + const getInitialViewMode = (): ViewMode => { + if (savedViewMode === 'generation_flow') return 'generation_flow'; + if (isLoggedIn()) return 'generation_flow'; + return 'landing'; + }; + + const [viewMode, setViewMode] = useState(getInitialViewMode()); + const [initialTab, setInitialTab] = useState('새 프로젝트 만들기'); + const [analysisData, setAnalysisData] = useState( + parseSavedAnalysisData() + ); + const [error, setError] = useState(null); + const [isAnalysisComplete, setIsAnalysisComplete] = useState(false); + const [afterLoadTarget, setAfterLoadTarget] = useState('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(() => { @@ -57,8 +147,8 @@ const App: React.FC = () => { setIsProcessingCallback(true); handleKakaoCallback(code); } - // 콜백이 아닌 일반 진입 — 광고 유입 추적 - else { + // 콜백이 아닌 일반 진입이고 랜딩이 첫 화면이면 ViewContent 발화 + else if (viewMode === 'landing') { trackViewContent(); } }, []); @@ -82,15 +172,21 @@ const App: React.FC = () => { await trackCompleteRegistration(); } - // 로그인 전 특정 화면(예: /video/{id})에서 유도된 로그인이면 그 경로로 복귀 + const savedData = localStorage.getItem(ANALYSIS_DATA_KEY); + if (savedData) { + // 분석 데이터가 있으면 에셋 관리(step 1)부터 시작 + // 이전에 저장된 wizard step이 URL 입력(-2) 등으로 남아있을 수 있으므로 초기화 + localStorage.removeItem('castad_wizard_step'); + localStorage.removeItem('castad_active_item'); + } const redirectPath = sessionStorage.getItem('castad_login_redirect'); sessionStorage.removeItem('castad_login_redirect'); if (redirectPath && redirectPath !== '/') { window.location.href = redirectPath; return; } - // 그 외(사이드바 게이트 등)는 GenerationFlow 가 castad_login_next_item 으로 자체 복귀 처리 - setInitialTab(NAV.HOME); + setInitialTab('새 프로젝트 만들기'); + setViewMode('generation_flow'); } catch (err) { console.error('Token callback failed:', err); alert(t('app.loginFailed')); @@ -124,6 +220,231 @@ const App: React.FC = () => { } }; + // viewMode 변경 시 localStorage에 저장 + useEffect(() => { + localStorage.setItem(VIEW_MODE_KEY, viewMode); + }, [viewMode]); + + // 스크롤 이벤트 핸들러 - 첫 번째 섹션에서 두 번째 섹션으로 넘어갈 때 0~1 값 계산 + useEffect(() => { + const container = containerRef.current; + if (!container || viewMode !== 'landing') return; + + const handleScroll = () => { + const scrollTop = container.scrollTop; + const sectionHeight = container.clientHeight; + // 첫 번째 섹션 스크롤 진행률 (0 ~ 1) + const progress = Math.min(1, Math.max(0, scrollTop / sectionHeight)); + setScrollProgress(progress); + }; + + container.addEventListener('scroll', handleScroll); + return () => container.removeEventListener('scroll', handleScroll); + }, [viewMode]); + + const scrollToSection = (index: number) => { + if (containerRef.current) { + const h = containerRef.current.clientHeight; + containerRef.current.scrollTo({ + top: h * index, + behavior: 'smooth' + }); + } + }; + + // 크롤링 응답 유효성 검사 + const validateCrawlingResponse = (data: CrawlingResponse): boolean => { + // 필수 필드 존재 여부 확인 + if (!data) return false; + if (!data.processed_info) return false; + if (!data.marketing_analysis) return false; + + // marketing_analysis 내부 필드 기본값 보장 + if (!data.marketing_analysis.brand_identity) { + data.marketing_analysis.brand_identity = { + location_feature_analysis: '', + concept_scalability: '' + }; + } + if (!data.marketing_analysis.market_positioning) { + data.marketing_analysis.market_positioning = { + category_definition: '', + core_value: '' + }; + } + if (!data.marketing_analysis.target_persona) { + data.marketing_analysis.target_persona = []; + } + if (!data.marketing_analysis.selling_points) { + data.marketing_analysis.selling_points = []; + } + if (!data.marketing_analysis.target_keywords) { + data.marketing_analysis.target_keywords = []; + } + + // processed_info 내부 필드 기본값 보장 + if (!data.processed_info.customer_name) { + data.processed_info.customer_name = '알 수 없음'; + } + if (!data.processed_info.region) { + data.processed_info.region = ''; + } + if (!data.processed_info.detail_region_info) { + data.processed_info.detail_region_info = ''; + } + + // image_list 기본값 보장 + if (!data.image_list) { + data.image_list = []; + } + + return true; + }; + + const handleStartAnalysis = async (url: string) => { + if (!url.trim()) return; + + setAfterLoadTarget('analysis'); + setViewMode('loading'); + setIsAnalysisComplete(false); + setError(null); + + try { + const data = await crawlUrl(url); + + // 응답 유효성 검사 + if (!validateCrawlingResponse(data)) { + throw new Error(t('app.invalidUrl')); + } + + setAnalysisData(data); + localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data)); + saveSearchHistory({ type: 'url', value: url }); + setIsAnalysisComplete(true); + } catch (err) { + console.error('Crawling failed:', err); + setError(t('app.analysisError')); + setViewMode('landing'); + } + }; + + // 업체명 자동완성으로 분석 시작 + const handleAutocomplete = async (request: AutocompleteRequest) => { + setAfterLoadTarget('analysis'); + setViewMode('loading'); + setIsAnalysisComplete(false); + setError(null); + + try { + const data = await autocomplete(request); + + // 응답 유효성 검사 + if (!validateCrawlingResponse(data)) { + throw new Error(t('app.autocompleteError')); + } + + setAnalysisData(data); + localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data)); + saveSearchHistory({ type: 'name', value: request.title.replace(/<[^>]*>/g, ''), address: request.address, roadAddress: request.roadAddress }); + setIsAnalysisComplete(true); + } catch (err) { + console.error('Autocomplete failed:', err); + setError(t('app.autocompleteError')); + setViewMode('landing'); + } + }; + + // 업체명·주소 수동 입력으로 마케팅 분석 API 호출 + const handleManualInput = async (businessName: string, address: string, category: string) => { + setAfterLoadTarget('generation_flow'); + setViewMode('loading'); + setIsAnalysisComplete(false); + setError(null); + + try { + const data = await marketingAnalysis(businessName, address, category); + + if (!validateCrawlingResponse(data)) { + throw new Error(t('app.autocompleteError')); + } + + setAnalysisData(data); + localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data)); + saveSearchHistory({ type: 'name', value: businessName, address, roadAddress: address }); + setIsAnalysisComplete(true); + } catch (err) { + console.error('Marketing analysis failed:', err); + setError(t('app.autocompleteError')); + setViewMode('landing'); + } + }; + + // 테스트 데이터로 브랜드 분석 페이지 이동 + const handleTestData = (data: CrawlingResponse) => { + const tagged = { ...data, _isTestData: true }; + setAnalysisData(tagged); + localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged)); + setViewMode('analysis'); + }; + + // 언어 변경 시 테스트 데이터 다시 로드 + useEffect(() => { + const saved = localStorage.getItem(ANALYSIS_DATA_KEY); + if (!saved) return; + try { + const parsed = JSON.parse(saved); + if (!parsed._isTestData) return; + const jsonFile = i18n.language === 'en' ? '/example_analysis_en.json' : '/example_analysis.json'; + fetch(jsonFile) + .then(res => res.json()) + .then((data: CrawlingResponse) => { + const tagged = { ...data, _isTestData: true }; + setAnalysisData(tagged); + localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged)); + }) + .catch(err => console.error('Failed to reload test data:', err)); + } catch { /* ignore */ } + }, [i18n.language]); + + const handleToLogin = async () => { + // 이미 로그인된 상태면 바로 generation_flow로 이동 + if (isLoggedIn()) { + // 분석 데이터가 있으면 이전 wizard step 초기화 (에셋 관리부터 시작하도록) + const savedData = localStorage.getItem(ANALYSIS_DATA_KEY); + if (savedData) { + localStorage.removeItem('castad_wizard_step'); + localStorage.removeItem('castad_active_item'); + } + setInitialTab('새 프로젝트 만들기'); + setViewMode('generation_flow'); + return; + } + + // 로그인 안 된 상태면 카카오 로그인 페이지로 리다이렉션 + try { + const { getKakaoLoginUrl } = await import('./utils/api'); + const response = await getKakaoLoginUrl(); + window.location.href = response.auth_url; + } catch (err) { + console.error('Failed to get Kakao login URL:', err); + alert(t('app.loginUrlFailed')); + } + }; + + const handleLoginSuccess = () => { + setInitialTab('새 프로젝트 만들기'); + setViewMode('generation_flow'); + }; + + const handleGoBack = () => { + // localStorage 정리 + localStorage.removeItem(VIEW_MODE_KEY); + localStorage.removeItem(ANALYSIS_DATA_KEY); + localStorage.removeItem('castad_wizard_step'); + localStorage.removeItem('castad_active_item'); + setViewMode('landing'); + }; + // Social OAuth 콜백 페이지 처리 const pathname = window.location.pathname; @@ -146,14 +467,12 @@ const App: React.FC = () => { return ; } - // 썰박스 공유 페이지 (/ssul/{id}) — 위와 동일한 패턴, 비로그인 열람 가능. - // /video/{id} 와 경로를 나눈 이유: video.id 와 ssul_content.id 가 독립 시퀀스라 - // 값이 겹치므로, 한 경로로 합치면 다른 종류의 콘텐츠가 열린다. const ssulDetailMatch = pathname.match(/^\/ssul\/(\d+)$/); if (ssulDetailMatch) { return ; } + // 카카오 콜백 처리 중 로딩 화면 표시 if (isProcessingCallback) { return ( @@ -165,8 +484,71 @@ const App: React.FC = () => { ); } - // 그 외 전 경로는 ADO2 콘텐츠 갤러리를 첫 화면으로 하는 GenerationFlow 로 수렴 - return ; + if (viewMode === 'loading') { + return ( + setViewMode(afterLoadTarget)} + /> + ); + } + + if (viewMode === 'analysis' && analysisData) { + return ( + + ); + } + + if (viewMode === 'login') { + return setViewMode('analysis')} onLogin={handleLoginSuccess} />; + } + + if (viewMode === 'generation_flow') { + return ( + + ); + } + + // 로그인된 상태에서 "시작하기" 버튼 클릭 + const handleHeaderStart = () => { + setInitialTab('새 프로젝트 만들기'); + setViewMode('generation_flow'); + }; + + return ( +
+
+
+ scrollToSection(1)} + error={error} + scrollProgress={scrollProgress} + /> +
+
+ scrollToSection(0)} + onNext={() => scrollToSection(0)} + /> +
+
+ scrollToSection(0)} /> +
+
+ ); }; export default App; diff --git a/src/components/ContentCardSocialActions.tsx b/src/components/ContentCardSocialActions.tsx index 12baddf..585d271 100644 --- a/src/components/ContentCardSocialActions.tsx +++ b/src/components/ContentCardSocialActions.tsx @@ -1,7 +1,7 @@ -import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; +import React, { useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { toggleVideoLike, isLoggedIn } from '../utils/api'; +import { API_URL, toggleVideoLike, isLoggedIn } from '../utils/api'; +import { buildContentShareUrl, tryNativeShare } from '../utils/nativeShare'; import { ContentType } from '../types/api'; import LoginPromptModal from './LoginPromptModal'; @@ -33,102 +33,29 @@ const ContentCardSocialActions: React.FC = ({ const [likeCount, setLikeCount] = useState(initialLikeCount); const [isLiked, setIsLiked] = useState(initialIsLiked); const [showLoginModal, setShowLoginModal] = useState(false); - const [copied, setCopied] = useState(false); - const [shareMenuOpen, setShareMenuOpen] = useState(false); - const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null); - const [anchorRect, setAnchorRect] = useState<{ top: number; bottom: number; left: number } | null>(null); - - const shareBtnRef = useRef(null); - const shareMenuRef = useRef(null); const likeDebounceRef = useRef | null>(null); - // 종류별 공개 페이지가 다르다 — 합치면 id 가 겹치는 다른 콘텐츠가 열린다 - const shareUrl = `${window.location.origin}/${contentType === 'ssul' ? 'ssul' : 'video'}/${videoId}`; + const shareUrl = buildContentShareUrl(API_URL, videoId, contentType); + const shareTitle = storeName || t('videoDetail.kakaoDefaultTitle'); + const shareDescription = t('videoDetail.kakaoDescription', { region: region ?? '' }); - useEffect(() => { - if (!shareMenuOpen) return; + const handleShareBtnClick = async (e: React.MouseEvent) => { + e.stopPropagation(); - const closeMenu = () => setShareMenuOpen(false); - - const handleClickOutside = (e: MouseEvent) => { - if ( - shareMenuRef.current && !shareMenuRef.current.contains(e.target as Node) && - shareBtnRef.current && !shareBtnRef.current.contains(e.target as Node) - ) { - closeMenu(); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - window.addEventListener('scroll', closeMenu, true); - window.addEventListener('resize', closeMenu); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - window.removeEventListener('scroll', closeMenu, true); - window.removeEventListener('resize', closeMenu); - }; - }, [shareMenuOpen]); - - // 메뉴가 뷰포트 밖으로 넘어가지 않도록 실제 렌더된 크기 기준으로 위치 보정 - useLayoutEffect(() => { - if (!shareMenuOpen || !anchorRect || !shareMenuRef.current) return; - const margin = 8; - const menuRect = shareMenuRef.current.getBoundingClientRect(); - - let left = anchorRect.left; - if (left + menuRect.width > window.innerWidth - margin) { - left = window.innerWidth - menuRect.width - margin; + const handled = await tryNativeShare({ + title: shareTitle, + text: shareDescription, + url: shareUrl, + }); + if (handled) { + return; } - left = Math.max(margin, left); - let top = anchorRect.bottom + 6; - if (top + menuRect.height > window.innerHeight - margin) { - top = anchorRect.top - menuRect.height - 6; - } - top = Math.max(margin, top); - - setMenuPos((prev) => (prev && prev.top === top && prev.left === left ? prev : { top, left })); - }, [shareMenuOpen, anchorRect]); - - const handleCopyLink = async () => { try { await navigator.clipboard.writeText(shareUrl); } catch { // clipboard API 미지원 환경에서는 무시 } - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - const handleKakaoShare = () => { - const kakao = window.Kakao; - if (kakao?.Share) { - kakao.Share.sendDefault({ - objectType: 'feed', - content: { - title: storeName || t('videoDetail.kakaoDefaultTitle'), - description: t('videoDetail.kakaoDescription', { region: region ?? '' }), - imageUrl: 'https://ado2.o2osolution.ai/favicon_48.svg', - link: { mobileWebUrl: shareUrl, webUrl: shareUrl }, - }, - buttons: [{ title: t('videoDetail.kakaoButtonTitle'), link: { mobileWebUrl: shareUrl, webUrl: shareUrl } }], - }); - } else if (navigator.share) { - navigator.share({ url: shareUrl }).catch(() => {}); - } else { - handleCopyLink(); - } - setShareMenuOpen(false); - }; - - const handleFacebookShare = () => { - window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600'); - setShareMenuOpen(false); - }; - - const handleTwitterShare = () => { - window.open(`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600'); - setShareMenuOpen(false); }; const handleLikeClick = (e: React.MouseEvent) => { @@ -159,17 +86,6 @@ const ContentCardSocialActions: React.FC = ({ }, 500); }; - const handleShareBtnClick = (e: React.MouseEvent) => { - e.stopPropagation(); - const rect = shareBtnRef.current?.getBoundingClientRect(); - if (rect) { - // 초기 추정 위치(측정 전 첫 렌더용) — useLayoutEffect가 실제 크기 기준으로 다시 보정한다 - setAnchorRect({ top: rect.top, bottom: rect.bottom, left: rect.left }); - setMenuPos({ top: rect.bottom + 6, left: rect.left }); - } - setShareMenuOpen((v) => !v); - }; - return (
e.stopPropagation()}> - {shareMenuOpen && menuPos && createPortal( -
- - - - -
, - document.body - )} - {showLoginModal && ( setShowLoginModal(false)} /> )} diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100755 index 0000000..f5ee1ef --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,64 @@ + +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { getKakaoLoginUrl, isLoggedIn } from '../utils/api'; +import LanguageSwitch from './LanguageSwitch'; + +interface HeaderProps { + onStartClick?: () => void; +} + +const Header: React.FC = ({ onStartClick }) => { + const { t } = useTranslation(); + const [isLoading, setIsLoading] = useState(false); + const loggedIn = isLoggedIn(); + + const handleLogin = async () => { + if (isLoading) return; + + setIsLoading(true); + try { + const response = await getKakaoLoginUrl(); + window.location.href = response.auth_url; + } catch (err) { + console.error('Failed to get Kakao login URL:', err); + alert(t('header.loginFailedAlert')); + setIsLoading(false); + } + }; + + const handleStart = () => { + if (onStartClick) { + onStartClick(); + } + }; + + return ( +
+
+ ADO2 +
+
+ + {loggedIn ? ( + + ) : ( + + )} +
+
+ ); +}; + +export default Header; diff --git a/src/components/SocialPostingModal.tsx b/src/components/SocialPostingModal.tsx index ee26052..df11f46 100644 --- a/src/components/SocialPostingModal.tsx +++ b/src/components/SocialPostingModal.tsx @@ -5,6 +5,9 @@ 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; @@ -119,6 +122,8 @@ const SocialPostingModal: React.FC = ({ onGoToCalendar, }) => { const { t } = useTranslation(); + const tutorial = useTutorial(); + const hasBeenOpenedRef = useRef(false); const [socialAccounts, setSocialAccounts] = useState([]); const [selectedChannel, setSelectedChannel] = useState(''); const [title, setTitle] = useState(''); @@ -180,16 +185,50 @@ const SocialPostingModal: React.FC = ({ return () => { document.body.style.overflow = ''; }; }, [isOpen]); - // 소셜 계정 로드 + // 모달 오픈/닫힘 시 튜토리얼 트리거 useEffect(() => { - if (!isOpen) return; + 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]); - const now = Date.now(); + // 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) { + loadedForTaskIdRef.current = null; + return; + } loadSocialAccounts(); - // SEO 자동 채움 키. ADO2 는 task_id, 썰박스는 task_id 가 없어(빈 문자열) - // (type, video_id) 로 키를 만든다 — 없으면 썰박스는 자동 채움이 통째로 스킵됐다. + if (video?.title) { + setTitle(video.title); + setDescription(video.description || ''); + setTags((video.hashtags || []).join(',')); + setIsLoadingAutoDescription(false); + return; + } + + const now = Date.now(); const seoKey = video?.type === 'ssul' ? `ssul:${video.video_id}` @@ -208,7 +247,7 @@ const SocialPostingModal: React.FC = ({ setTags(cached.tags); } } - }, [isOpen, video?.task_id, video?.type, video?.video_id]); + }, [isOpen, video?.task_id, video?.type, video?.video_id, video?.title, video?.description, video?.hashtags]); const loadSocialAccounts = async () => { setIsLoadingAccounts(true); @@ -251,11 +290,8 @@ const SocialPostingModal: React.FC = ({ content_type: video.type, task_id: isSsul ? String(video.video_id) : video.task_id, }; - // Call autoSEO API - console.log('[Upload] Request payload:', requestPayload); const autoSeoResponse = await getAutoSeoYoutube(requestPayload); - // 각 필드가 있을 때만 덮어씌움 (기존 값 보호) if (autoSeoResponse.title) setTitle(autoSeoResponse.title); if (autoSeoResponse.description) setDescription(autoSeoResponse.description); if (autoSeoResponse.keywords) setTags(autoSeoResponse.keywords.join(',')); @@ -266,7 +302,6 @@ const SocialPostingModal: React.FC = ({ }); } catch (error) { console.error('Failed to load autocomplete:', error); - // 실패해도 사용자에게 별도 알림 없이 조용히 처리 } finally { setIsLoadingAutoDescription(false); } @@ -839,6 +874,16 @@ 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..1c7d5a3 --- /dev/null +++ b/src/components/Tutorial/TutorialOverlay.tsx @@ -0,0 +1,314 @@ +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 = ({ + hints, + currentIndex, + onNext, + onPrev, + onSkip, + groupProgress, +}) => { + const { t } = useTranslation(); + const [targetRect, setTargetRect] = useState(null); + const tooltipRef = React.useRef(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 ( +
+ {spotlightRect ? ( + <> +
+
+
+
+
+ + ) : null} + +
e.stopPropagation()} + > +

{t(hint.titleKey)}

+

{t(hint.descriptionKey)}

+ {hint.noteKey && ( +

{t(hint.noteKey)}

+ )} + +
+ + {groupProgress ? groupProgress.groupOffset + 1 : currentIndex + 1} / {groupProgress ? groupProgress.groupTotal : hints.length} + +
+ + {currentIndex > 0 && ( + + )} + {(hint.clickToAdvance !== true || isFinish) && ( + + )} +
+
+
+
+ ); +}; + +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..1d25bad --- /dev/null +++ b/src/components/Tutorial/tutorialSteps.ts @@ -0,0 +1,377 @@ +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', + }, + ], + }, +]; diff --git a/src/components/Tutorial/useTutorial.ts b/src/components/Tutorial/useTutorial.ts new file mode 100644 index 0000000..0b2fdb5 --- /dev/null +++ b/src/components/Tutorial/useTutorial.ts @@ -0,0 +1,220 @@ +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(null); + 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, 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, + }; +} diff --git a/src/components/VideoDetailContent.tsx b/src/components/VideoDetailContent.tsx index a206186..d21370b 100644 --- a/src/components/VideoDetailContent.tsx +++ b/src/components/VideoDetailContent.tsx @@ -7,8 +7,11 @@ import { deleteComment, toggleVideoLike, isLoggedIn, + getUserMe, + API_URL, } from '../utils/api'; -import { VideoDetailItem, CommentItem } from '../types/api'; +import { VideoDetailItem, CommentItem, UserMeResponse } from '../types/api'; +import { buildVideoShareUrl, tryNativeShare } from '../utils/nativeShare'; import LoginPromptModal from './LoginPromptModal'; interface VideoDetailContentProps { @@ -30,7 +33,6 @@ const VideoDetailContent: React.FC = ({ videoId, isModa const [copied, setCopied] = useState(false); const [shareMenuOpen, setShareMenuOpen] = useState(false); - const [shareMenuUpward, setShareMenuUpward] = useState(false); const [isLandscape, setIsLandscape] = useState(false); const [showLoginModal, setShowLoginModal] = useState(false); @@ -43,16 +45,7 @@ const VideoDetailContent: React.FC = ({ videoId, isModa const [commentSubmitting, setCommentSubmitting] = useState(false); const commentTextareaRef = useRef(null); - const [commentNickname, setCommentNickname] = useState(''); - const [commentAvatarSeedIdx, setCommentAvatarSeedIdx] = useState(0); - - // 고정 seed 목록: 브라우저가 캐싱하여 중복 요청 없음 - const AVATAR_SEEDS = ['42', '77', '123', '256', '512', '888', '1024', '2048', '3141', '9999']; - const commentAvatarSeed = AVATAR_SEEDS[commentAvatarSeedIdx % AVATAR_SEEDS.length]; - - const handleChangeAvatar = useCallback(() => { - setCommentAvatarSeedIdx(prev => (prev + 1) % AVATAR_SEEDS.length); - }, []); + const [currentUser, setCurrentUser] = useState(null); const fetchComments = useCallback(async (page: number, append = false) => { setCommentsLoading(true); @@ -93,6 +86,13 @@ const VideoDetailContent: React.FC = ({ videoId, isModa fetchComments(1); }, [videoId, fetchComments]); + useEffect(() => { + if (!authed) return; + getUserMe().then(setCurrentUser).catch((err) => { + console.error('Failed to fetch current user:', err); + }); + }, [authed]); + const formatDate = (dateString: string) => { const date = new Date(dateString); return t('videoDetail.dateFormat', { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate() }); @@ -103,7 +103,9 @@ const VideoDetailContent: React.FC = ({ videoId, isModa return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`; }; - const shareUrl = `${window.location.origin}/video/${videoId}`; + const shareUrl = buildVideoShareUrl(API_URL, videoId); + const shareTitle = video?.store_name ?? t('videoDetail.kakaoDefaultTitle'); + const shareDescription = t('videoDetail.kakaoDescription', { region: video?.region ?? '' }); const handleCopyLink = async () => { try { @@ -115,6 +117,18 @@ const VideoDetailContent: React.FC = ({ videoId, isModa setTimeout(() => setCopied(false), 2000); }; + const handleShareButtonClick = async () => { + const handled = await tryNativeShare({ + title: shareTitle, + text: shareDescription, + url: shareUrl, + }); + if (handled) { + return; + } + setShareMenuOpen(v => !v); + }; + const handleKakaoShare = () => { const kakao = window.Kakao; if (kakao?.Share) { @@ -159,23 +173,6 @@ const VideoDetailContent: React.FC = ({ videoId, isModa return () => document.removeEventListener('mousedown', handleClickOutside); }, [shareMenuOpen]); - // 모달 내부는 스크롤 영역(.video-detail-body)이 곧 화면 경계라, 버튼이 그 하단에 - // 가까우면 아래로 펼치는 메뉴가 잘려서 안 보인다. 열기 직전 남은 공간을 재서 뒤집는다. - const handleToggleShareMenu = () => { - if (!shareMenuOpen) { - const btnRect = shareMenuRef.current?.getBoundingClientRect(); - if (btnRect) { - const scrollBox = shareMenuRef.current?.closest('.video-detail-body'); - const boundaryBottom = scrollBox - ? scrollBox.getBoundingClientRect().bottom - : window.innerHeight; - const ESTIMATED_MENU_HEIGHT = 220; - setShareMenuUpward(btnRect.bottom + ESTIMATED_MENU_HEIGHT > boundaryBottom); - } - } - setShareMenuOpen(v => !v); - }; - const likeDebounceRef = React.useRef | null>(null); const handleLike = () => { @@ -215,13 +212,11 @@ const VideoDetailContent: React.FC = ({ videoId, isModa if (!commentInput.trim() || commentSubmitting) return; setCommentSubmitting(true); try { - await postVideoComment(videoId, commentInput.trim(), commentNickname); + await postVideoComment(videoId, commentInput.trim()); setCommentInput(''); if (commentTextareaRef.current) { commentTextareaRef.current.style.height = 'auto'; } - setCommentNickname(''); - setCommentAvatarSeedIdx(prev => (prev + 1) % AVATAR_SEEDS.length); await fetchComments(1); } catch (err) { console.error('Failed to post comment:', err); @@ -310,7 +305,7 @@ const VideoDetailContent: React.FC = ({ videoId, isModa
{shareMenuOpen && ( -
+
{/* 카카오톡 */}
- {/* 댓글 작성자 프로필 선택 */} - {authed && ( + {/* 댓글 작성자 프로필 (카카오 로그인 정보) */} + {authed && currentUser && (
- {t('videoDetail.changeAvatarTitle')} - setCommentNickname(e.target.value)} - maxLength={20} - /> + {currentUser.profile_image_url && ( + {currentUser.nickname} + )} + {currentUser.nickname}
)} @@ -421,11 +408,13 @@ const VideoDetailContent: React.FC = ({ videoId, isModa
    {comments.map((c) => (
  • - avatar + {c.profile_image_url && ( + {c.nickname + )}
    {c.nickname || t('videoDetail.anonymous')} @@ -450,11 +439,13 @@ const VideoDetailContent: React.FC = ({ videoId, isModa
      {c.replies.map((r) => (
    • - avatar + {r.profile_image_url && ( + {r.nickname + )}
      {r.nickname || t('videoDetail.anonymous')} diff --git a/src/locales/en.json b/src/locales/en.json index cc1c758..0d1fe73 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -13,7 +13,7 @@ }, "sidebar": { "dashboard": "Dashboard", - "newProject": "New Project", + "newProject": "Create New Project", "ado2Contents": "ADO2 Contents", "myContents": "My Contents", "myInfo": "My Info", @@ -21,9 +21,197 @@ "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", - "inquiry": "Feedback" + "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.", @@ -158,29 +346,6 @@ "loggingIn": "Logging in...", "kakaoStart": "Start with Kakao" }, - "pipelineTabs": { - "ariaLabel": "Content type", - "ado2": "ADO2", - "ssul": "Ssulbox" - }, - "ssulbox": { - "steps": { - "pick": "Select", - "making": "Generating", - "done": "Done" - }, - "create": { - "step1": "STEP 1 · Pick a story", - "step2": "STEP 2 · Pick a place", - "searchPlaceholder": "Business name, or paste a Naver Map link", - "searchButton": "Search", - "searching": "Searching", - "cost": "Cost", - "costValue": "1 credit", - "submit": "Create Ssulbox", - "submitting": "Requesting…" - } - }, "urlInput": { "searchTypeBusinessName": "Business Name", "placeholderBusinessName": "Enter a business name", @@ -429,10 +594,10 @@ }, "myInfo": { "title": "My Info", - "tabContents": "My Contents", - "tabCalendar": "Calendar", - "tabPayment": "Payment", - "tabBusiness": "SNS", + "tabBasic": "Basic Info", + "tabPayment": "Payment Info", + "tabBusiness": "Social Channel Management", + "basicPlaceholder": "Basic information settings are coming soon.", "paymentPlaceholder": "Payment information settings are coming soon.", "myBusiness": "My Business", "noBusinessTitle": "No registered business yet", @@ -461,7 +626,9 @@ "chargeSubmit": "Submit", "chargeSubmitting": "Submitting...", "chargeSuccess": "Your top-up request has been submitted!", - "chargeConfirm": "OK" + "chargeConfirm": "OK", + "tabContents": "My Contents", + "tabCalendar": "Calendar" }, "ado2Contents": { "title": "ADO2 Contents", @@ -545,8 +712,29 @@ "scheduled": "Planned", "failed": "Failed" }, - "months": ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"], - "days": ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"], + "months": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ], + "days": [ + "Sun", + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat" + ], "yearMonth": "{{month}} {{year}}", "monthDay": "{{month}} {{day}}", "loading": "Loading...", @@ -564,14 +752,13 @@ }, "loginPrompt": { "title": "Login Required", - "description": "Sign in with Kakao in a few seconds\nto keep going.", - "loginBtn": "Continue with Kakao" + "loginBtn": "Login", + "description": "Sign in with Kakao in a few seconds\nto keep going." }, "videoDetail": { "dateFormat": "{{month}}/{{day}}/{{year}}", "kakaoDefaultTitle": "ADO2 Video", "kakaoDescription": "{{region}} · ADO2 AI Marketing Video", - "kakaoButtonTitle": "Watch Video", "deletedComment": "(This comment has been deleted.)", "closeAriaLabel": "Close", "share": "Share", @@ -581,8 +768,6 @@ "shareTwitter": "X (Twitter)", "copyUrl": "Copy URL", "commentsTitle": "Comments", - "changeAvatarTitle": "Click to change avatar", - "nicknamePlaceholder": "Author name", "commentPlaceholder": "Write a comment...", "commentLoginRequired": "Please log in to write a comment", "commentSubmitting": "Submitting", @@ -603,5 +788,28 @@ "autocompleteError": "No results found. Please check your input and try again.", "autocompleteGeneralError": "An error occurred while retrieving business information. Please try again.", "pageComingSoon": "{{page}} page is coming soon." + }, + "pipelineTabs": { + "ariaLabel": "Content type", + "ado2": "ADO2", + "ssul": "Ssulbox" + }, + "ssulbox": { + "steps": { + "pick": "Select", + "making": "Generating", + "done": "Done" + }, + "create": { + "step1": "STEP 1 · Pick a story", + "step2": "STEP 2 · Pick a place", + "searchPlaceholder": "Business name, or paste a Naver Map link", + "searchButton": "Search", + "searching": "Searching", + "cost": "Cost", + "costValue": "1 credit", + "submit": "Create Ssulbox", + "submitting": "Requesting…" + } } } diff --git a/src/locales/ko.json b/src/locales/ko.json index 12e74f4..7ca068b 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -21,12 +21,200 @@ "credits": "보유 크레딧: {{count}}", "loggingOut": "로그아웃 중...", "logout": "로그아웃", + "tutorialRestart": "튜토리얼 다시 보기", + "tutorial": "튜토리얼", + "tutorialOn": "튜토리얼 켜기", + "tutorialOff": "튜토리얼 끄기", + "inquiry": "고객의견", "login": "로그인", - "settings": "설정", - "inquiry": "고객의견" + "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":"㈜에이아이오투오", + "company": "㈜에이아이오투오", "businessNumber": "사업자 등록번호 : 620-87-00810 | 대표 : 안성민", "headquarters": "본사 : 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호", "researchCenter": "연구소 : 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)", @@ -109,7 +297,8 @@ "close": "닫기", "doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요.", "goToCalendar": "캘린더에서 확인", - "scheduleConflict": "예약 시간이 충돌하여 {{time}}으로 조정되었습니다." }, + "scheduleConflict": "예약 시간이 충돌하여 {{time}}으로 조정되었습니다." + }, "landing": { "hero": { "searchTypeLabel": "업체명 | URL", @@ -157,78 +346,6 @@ "loggingIn": "로그인 중...", "kakaoStart": "카카오로 시작하기" }, - "pipelineTabs": { - "ariaLabel": "생성할 콘텐츠 종류", - "ado2": "ADO2", - "ssul": "썰박스" - }, - "ssulbox": { - "steps": { - "pick": "선택", - "making": "생성", - "done": "완성" - }, - "create": { - "step1": "STEP 1 · 시나리오 선택", - "step2": "STEP 2 · 업장 검색", - "searchPlaceholder": "업체명 또는 URL을 입력하세요.", - "searchButton": "검색", - "searching": "검색 중", - "placeLoading": "가게를 찾는 중… (몇 초 걸려요)", - "hintUrlOk": "네이버 링크로 이 가게를 직접 크롤링합니다.", - "hintDefault": "가게를 선택하면 그 가게의 사진과 정보를 크롤링해 썰박스에 씁니다.", - "hintScenario": "{{name}} 시나리오로 약 5~6분 소요됩니다.", - "noResult": "검색 결과가 없어요. 이름을 바꾸거나 네이버 지도 링크를 붙여넣어 주세요.", - "cost": "생성 비용", - "costValue": "1 크레딧", - "submit": "썰박스 만들기", - "submitting": "요청 중…" - }, - "making": { - "title": "썰박스를 만들고 있어요", - "step1": "대본 생성 중", - "step2": "스토리보드 구성 중", - "step3": "이미지·목소리 생성 중", - "step4": "영상 합성 중", - "takesTime": "약 5~6분 소요됩니다. 다른 작업을 하셔도 계속 진행됩니다.", - "failTitle": "생성에 실패했어요", - "failDefault": "잠시 후 다시 시도해주세요.", - "refunded": "차감된 크레딧은 환불되었습니다.", - "retry": "다시 만들기" - }, - "result": { - "title": "썰박스가 완성됐어요", - "download": "다운로드", - "downloading": "다운로드 중...", - "loadFailed": "영상을 불러오지 못했어요. 내 콘텐츠에서 확인해주세요.", - "scenarioLabel": "시나리오", - "createdLabel": "생성일시" - }, - "viewer": { - "untitled": "이름 없는 콘텐츠" - }, - "error": { - "createFailed": "생성 요청에 실패했어요. 잠시 후 다시 시도해주세요." - }, - "scenario": { - "joseon": { - "name": "조선왕", - "desc": "조선 27대 왕들의 실화 썰" - }, - "samgukji": { - "name": "삼국지", - "desc": "위·촉·오 영웅 28인의 야사" - }, - "greek": { - "name": "그리스·로마신화", - "desc": "올림포스 신·영웅들의 전설" - }, - "odyssey": { - "name": "오디세이", - "desc": "오디세우스 10년 귀향 대모험" - } - } - }, "urlInput": { "searchTypeBusinessName": "업체명", "placeholderBusinessName": "업체명을 입력하세요.", @@ -477,10 +594,10 @@ }, "myInfo": { "title": "내 정보", - "tabContents": "내 콘텐츠", - "tabCalendar": "콘텐츠 캘린더", + "tabBasic": "기본 정보", "tabPayment": "결제 정보", "tabBusiness": "소셜 채널 관리", + "basicPlaceholder": "기본 정보 설정 기능이 준비 중입니다.", "paymentPlaceholder": "결제 정보 설정 기능이 준비 중입니다.", "myBusiness": "내 비즈니스", "noBusinessTitle": "아직 등록된 비즈니스가 없어요", @@ -509,7 +626,9 @@ "chargeSubmit": "요청하기", "chargeSubmitting": "요청 중...", "chargeSuccess": "충전 요청이 완료 되었습니다!", - "chargeConfirm": "확인" + "chargeConfirm": "확인", + "tabContents": "내 콘텐츠", + "tabCalendar": "콘텐츠 캘린더" }, "ado2Contents": { "title": "ADO2 콘텐츠", @@ -593,8 +712,29 @@ "scheduled": "예약", "failed": "실패" }, - "months": ["1월","2월","3월","4월","5월","6월","7월","8월","9월","10월","11월","12월"], - "days": ["일","월","화","수","목","금","토"], + "months": [ + "1월", + "2월", + "3월", + "4월", + "5월", + "6월", + "7월", + "8월", + "9월", + "10월", + "11월", + "12월" + ], + "days": [ + "일", + "월", + "화", + "수", + "목", + "금", + "토" + ], "yearMonth": "{{year}}년 {{month}}", "monthDay": "{{month}} {{day}}일", "loading": "불러오는 중...", @@ -612,14 +752,13 @@ }, "loginPrompt": { "title": "로그인이 필요합니다.", - "description": "카카오 계정으로 간편하게 로그인하고\n이어서 진행해보세요.", - "loginBtn": "카카오 로그인" + "loginBtn": "로그인", + "description": "카카오 계정으로 간편하게 로그인하고\n이어서 진행해보세요." }, "videoDetail": { "dateFormat": "{{year}}년 {{month}}월 {{day}}일", "kakaoDefaultTitle": "ADO2 영상", "kakaoDescription": "{{region}} · ADO2 AI 마케팅 영상", - "kakaoButtonTitle": "영상 보기", "deletedComment": "(삭제된 댓글입니다.)", "closeAriaLabel": "닫기", "share": "공유하기", @@ -629,8 +768,6 @@ "shareTwitter": "X (트위터)", "copyUrl": "URL 복사", "commentsTitle": "댓글", - "changeAvatarTitle": "클릭하여 아바타 변경", - "nicknamePlaceholder": "작성자 이름", "commentPlaceholder": "댓글을 입력하세요...", "commentLoginRequired": "로그인 후 댓글을 작성할 수 있습니다", "commentSubmitting": "작성 중", @@ -651,5 +788,77 @@ "autocompleteError": "검색 정보를 찾을 수 없습니다. 입력 정보를 다시 확인해주세요.", "autocompleteGeneralError": "업체 정보 조회 중 오류가 발생했습니다. 다시 시도해주세요.", "pageComingSoon": "{{page}} 페이지 준비 중입니다." + }, + "pipelineTabs": { + "ariaLabel": "생성할 콘텐츠 종류", + "ado2": "ADO2", + "ssul": "썰박스" + }, + "ssulbox": { + "steps": { + "pick": "선택", + "making": "생성", + "done": "완성" + }, + "create": { + "step1": "STEP 1 · 시나리오 선택", + "step2": "STEP 2 · 업장 검색", + "searchPlaceholder": "업체명 또는 URL을 입력하세요.", + "searchButton": "검색", + "searching": "검색 중", + "placeLoading": "가게를 찾는 중… (몇 초 걸려요)", + "hintUrlOk": "네이버 링크로 이 가게를 직접 크롤링합니다.", + "hintDefault": "가게를 선택하면 그 가게의 사진과 정보를 크롤링해 썰박스에 씁니다.", + "hintScenario": "{{name}} 시나리오로 약 5~6분 소요됩니다.", + "noResult": "검색 결과가 없어요. 이름을 바꾸거나 네이버 지도 링크를 붙여넣어 주세요.", + "cost": "생성 비용", + "costValue": "1 크레딧", + "submit": "썰박스 만들기", + "submitting": "요청 중…" + }, + "making": { + "title": "썰박스를 만들고 있어요", + "step1": "대본 생성 중", + "step2": "스토리보드 구성 중", + "step3": "이미지·목소리 생성 중", + "step4": "영상 합성 중", + "takesTime": "약 5~6분 소요됩니다. 다른 작업을 하셔도 계속 진행됩니다.", + "failTitle": "생성에 실패했어요", + "failDefault": "잠시 후 다시 시도해주세요.", + "refunded": "차감된 크레딧은 환불되었습니다.", + "retry": "다시 만들기" + }, + "result": { + "title": "썰박스가 완성됐어요", + "download": "다운로드", + "downloading": "다운로드 중...", + "loadFailed": "영상을 불러오지 못했어요. 내 콘텐츠에서 확인해주세요.", + "scenarioLabel": "시나리오", + "createdLabel": "생성일시" + }, + "viewer": { + "untitled": "이름 없는 콘텐츠" + }, + "error": { + "createFailed": "생성 요청에 실패했어요. 잠시 후 다시 시도해주세요." + }, + "scenario": { + "joseon": { + "name": "조선왕", + "desc": "조선 27대 왕들의 실화 썰" + }, + "samgukji": { + "name": "삼국지", + "desc": "위·촉·오 영웅 28인의 야사" + }, + "greek": { + "name": "그리스·로마신화", + "desc": "올림포스 신·영웅들의 전설" + }, + "odyssey": { + "name": "오디세이", + "desc": "오디세우스 10년 귀향 대모험" + } + } } } diff --git a/src/pages/Dashboard/ADO2ContentsPage.tsx b/src/pages/Dashboard/ADO2ContentsPage.tsx index 04474ab..9d9b44d 100644 --- a/src/pages/Dashboard/ADO2ContentsPage.tsx +++ b/src/pages/Dashboard/ADO2ContentsPage.tsx @@ -163,7 +163,15 @@ const ADO2ContentsPage: React.FC = () => { } >
      - {video.thumbnail_url ? ( + {video.poster_url ? ( + {video.store_name} + ) : video.thumbnail_url ? ( {video.store_name} = ({ created_at: new Date().toISOString(), like_count: 0, comment_count: 0, + is_liked_by_me: false, } : null } diff --git a/src/pages/Dashboard/GenerationFlow.tsx b/src/pages/Dashboard/GenerationFlow.tsx index 9867e0f..4960c41 100755 --- a/src/pages/Dashboard/GenerationFlow.tsx +++ b/src/pages/Dashboard/GenerationFlow.tsx @@ -79,6 +79,7 @@ interface BusinessInfo { } interface GenerationFlowProps { + onHome?: () => void; initialActiveItem?: string; initialImageList?: ImageListItem[]; businessInfo?: BusinessInfo; @@ -94,6 +95,7 @@ interface GenerationFlowProps { // 3: 완료 (Completion) const GenerationFlow: React.FC = ({ + onHome, initialActiveItem = NAV.HOME, initialImageList = [], businessInfo, @@ -311,7 +313,7 @@ const GenerationFlow: React.FC = ({ setImageList(prev => [...newImages, ...prev]); }; - // 홈 버튼(로고) 클릭 시 모든 상태 초기화 후 ADO2 콘텐츠 갤러리로 이동 + // 홈 버튼(로고) 클릭 시 프로젝트 상태를 비우고, 부모가 있으면 랜딩으로 돌아간다. const handleHome = () => { clearProjectStorage(); localStorage.removeItem(ANALYSIS_DATA_KEY); @@ -321,6 +323,10 @@ const GenerationFlow: React.FC = ({ setAnalysisData(null); revokeAllPreviews(imageListRef.current); setImageList([]); + if (onHome) { + onHome(); + return; + } setActiveItem(NAV.HOME); }; diff --git a/src/pages/Dashboard/MyContentsPage.tsx b/src/pages/Dashboard/MyContentsPage.tsx index 43890f5..aab2752 100644 --- a/src/pages/Dashboard/MyContentsPage.tsx +++ b/src/pages/Dashboard/MyContentsPage.tsx @@ -264,7 +264,15 @@ const MyContentsPage: React.FC = ({ onNavigate, embedded }) : setSelectedVideoId(video.video_id) } > - {video.result_movie_url ? ( + {video.poster_url ? ( + {video.store_name} + ) : video.result_movie_url ? ( void; +} + +const DisplaySection: React.FC = ({ onStartClick }) => { + const { t } = useTranslation(); + // YouTube Shorts 영상 ID들 + const videos = [ + { id: 1, videoId: 'M3iuPZ59X1I' }, + { id: 2, videoId: 'JxWQxELDHSs' }, + { id: 3, videoId: 'c2ZdwhaB7S4' }, + ]; + + return ( +
      +
      + {/* Main visual frames container */} +
      + {videos.map((video, index) => ( +
      +