refactor: 랜딩·튜토리얼·헤더 제거
This commit is contained in:
parent
741539a023
commit
bda4c267dd
411
src/App.tsx
411
src/App.tsx
@ -1,28 +1,16 @@
|
||||
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import React, { 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 { 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';
|
||||
import { K, clearSessionStorage } from './utils/storageKeys';
|
||||
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';
|
||||
|
||||
type ViewMode = 'landing' | 'loading' | 'analysis' | 'login' | 'generation_flow';
|
||||
|
||||
const VIEW_MODE_KEY = K.VIEW_MODE;
|
||||
const ANALYSIS_DATA_KEY = K.ANALYSIS_DATA;
|
||||
const SESSION_KEY = 'castad_session_active';
|
||||
|
||||
// 새 탭/새 창에서 접근 시 localStorage 초기화 (sessionStorage로 현재 세션 확인)
|
||||
@ -40,79 +28,9 @@ const initializeOnNewSession = () => {
|
||||
initializeOnNewSession();
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const containerRef = useRef<HTMLElement>(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<ViewMode>(getInitialViewMode());
|
||||
const [initialTab, setInitialTab] = useState('새 프로젝트 만들기');
|
||||
const [analysisData, setAnalysisData] = useState<CrawlingResponse | null>(
|
||||
parseSavedAnalysisData()
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isAnalysisComplete, setIsAnalysisComplete] = useState(false);
|
||||
const [afterLoadTarget, setAfterLoadTarget] = useState<ViewMode>('analysis');
|
||||
const [scrollProgress, setScrollProgress] = useState(0);
|
||||
const { t } = useTranslation();
|
||||
const [initialTab, setInitialTab] = useState<string>(NAV.HOME);
|
||||
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(() => {
|
||||
@ -139,8 +57,8 @@ const App: React.FC = () => {
|
||||
setIsProcessingCallback(true);
|
||||
handleKakaoCallback(code);
|
||||
}
|
||||
// 콜백이 아닌 일반 진입이고 랜딩이 첫 화면이면 ViewContent 발화
|
||||
else if (viewMode === 'landing') {
|
||||
// 콜백이 아닌 일반 진입 — 광고 유입 추적
|
||||
else {
|
||||
trackViewContent();
|
||||
}
|
||||
}, []);
|
||||
@ -164,21 +82,15 @@ const App: React.FC = () => {
|
||||
await trackCompleteRegistration();
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
// 로그인 전 특정 화면(예: /video/{id})에서 유도된 로그인이면 그 경로로 복귀
|
||||
const redirectPath = sessionStorage.getItem('castad_login_redirect');
|
||||
sessionStorage.removeItem('castad_login_redirect');
|
||||
if (redirectPath && redirectPath !== '/') {
|
||||
window.location.href = redirectPath;
|
||||
return;
|
||||
}
|
||||
setInitialTab('새 프로젝트 만들기');
|
||||
setViewMode('generation_flow');
|
||||
// 그 외(사이드바 게이트 등)는 GenerationFlow 가 castad_login_next_item 으로 자체 복귀 처리
|
||||
setInitialTab(NAV.HOME);
|
||||
} catch (err) {
|
||||
console.error('Token callback failed:', err);
|
||||
alert(t('app.loginFailed'));
|
||||
@ -212,231 +124,6 @@ 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;
|
||||
|
||||
@ -459,6 +146,13 @@ const App: React.FC = () => {
|
||||
return <VideoDetailPage videoId={videoDetailMatch[1]} />;
|
||||
}
|
||||
|
||||
// 썰박스 공유 페이지 (/ssul/{id}) — 위와 동일한 패턴, 비로그인 열람 가능.
|
||||
// /video/{id} 와 경로를 나눈 이유: video.id 와 ssul_content.id 가 독립 시퀀스라
|
||||
// 값이 겹치므로, 한 경로로 합치면 다른 종류의 콘텐츠가 열린다.
|
||||
const ssulDetailMatch = pathname.match(/^\/ssul\/(\d+)$/);
|
||||
if (ssulDetailMatch) {
|
||||
return <SsulDetailPage contentId={ssulDetailMatch[1]} />;
|
||||
}
|
||||
|
||||
// 카카오 콜백 처리 중 로딩 화면 표시
|
||||
if (isProcessingCallback) {
|
||||
@ -471,71 +165,8 @@ const App: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (viewMode === 'loading') {
|
||||
return (
|
||||
<LoadingSection
|
||||
isComplete={isAnalysisComplete}
|
||||
onComplete={() => setViewMode(afterLoadTarget)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewMode === 'analysis' && analysisData) {
|
||||
return (
|
||||
<AnalysisResultSection
|
||||
onBack={handleGoBack}
|
||||
onGenerate={handleToLogin}
|
||||
data={analysisData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewMode === 'login') {
|
||||
return <LoginSection onBack={() => setViewMode('analysis')} onLogin={handleLoginSuccess} />;
|
||||
}
|
||||
|
||||
if (viewMode === 'generation_flow') {
|
||||
return (
|
||||
<GenerationFlow
|
||||
onHome={handleGoBack}
|
||||
initialActiveItem={initialTab}
|
||||
initialImageList={analysisData?.image_list || []}
|
||||
businessInfo={analysisData?.processed_info}
|
||||
initialAnalysisData={analysisData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 로그인된 상태에서 "시작하기" 버튼 클릭
|
||||
const handleHeaderStart = () => {
|
||||
setInitialTab('새 프로젝트 만들기');
|
||||
setViewMode('generation_flow');
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="landing-container" ref={containerRef}>
|
||||
<Header onStartClick={handleHeaderStart} />
|
||||
<section className="landing-section">
|
||||
<HeroSection
|
||||
onAnalyze={handleStartAnalysis}
|
||||
onAutocomplete={handleAutocomplete}
|
||||
onManualInput={handleManualInput}
|
||||
onNext={() => scrollToSection(1)}
|
||||
error={error}
|
||||
scrollProgress={scrollProgress}
|
||||
/>
|
||||
</section>
|
||||
<section className="landing-section">
|
||||
<WelcomeSection
|
||||
onStartClick={() => scrollToSection(0)}
|
||||
onNext={() => scrollToSection(0)}
|
||||
/>
|
||||
</section>
|
||||
<section className="landing-section">
|
||||
<DisplaySection onStartClick={() => scrollToSection(0)} />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
// 그 외 전 경로는 ADO2 콘텐츠 갤러리를 첫 화면으로 하는 GenerationFlow 로 수렴
|
||||
return <GenerationFlow initialActiveItem={initialTab} />;
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
||||
@ -1,64 +0,0 @@
|
||||
|
||||
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<HeaderProps> = ({ 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 (
|
||||
<header className="landing-header">
|
||||
<div className="header-logo">
|
||||
<img src="/assets/images/ado2-header-logo.svg" alt="ADO2" />
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<LanguageSwitch />
|
||||
{loggedIn ? (
|
||||
<button
|
||||
className="header-start-btn"
|
||||
onClick={handleStart}
|
||||
>
|
||||
{t('header.start')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="header-login-btn"
|
||||
onClick={handleLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? t('header.loading') : t('header.login')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@ -1,314 +0,0 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TutorialHint } from './tutorialSteps';
|
||||
|
||||
interface Rect {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
interface TooltipPos {
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
interface TutorialOverlayProps {
|
||||
hints: TutorialHint[];
|
||||
currentIndex: number;
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
onSkip: () => void;
|
||||
groupProgress?: { groupTotal: number; groupOffset: number; isLastKeyInGroup: boolean } | null;
|
||||
}
|
||||
|
||||
const PADDING = 8;
|
||||
|
||||
function getTargetRect(selector: string): Rect | null {
|
||||
const els = Array.from(document.querySelectorAll(selector));
|
||||
const el = els.find(e => {
|
||||
const r = (e as HTMLElement).getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
}) ?? els[0];
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
return { top: r.top, left: r.left, width: r.width, height: r.height, bottom: r.bottom };
|
||||
}
|
||||
|
||||
function getSpotlightRect(
|
||||
rect: Rect,
|
||||
padding: number,
|
||||
override?: { top?: number; right?: number; bottom?: number; left?: number }
|
||||
): Rect {
|
||||
const pTop = override?.top ?? padding;
|
||||
const pRight = override?.right ?? padding;
|
||||
const pBottom = override?.bottom ?? padding;
|
||||
const pLeft = override?.left ?? padding;
|
||||
const left = Math.max(0, Math.floor(rect.left - pLeft));
|
||||
const top = Math.max(0, Math.floor(rect.top - pTop));
|
||||
const right = Math.min(window.innerWidth, Math.ceil(rect.left + rect.width + pRight));
|
||||
const bottom = Math.min(window.innerHeight, Math.ceil(rect.top + rect.height + pBottom));
|
||||
|
||||
return {
|
||||
top,
|
||||
bottom,
|
||||
left,
|
||||
width: Math.max(0, right - left),
|
||||
height: Math.max(0, bottom - top),
|
||||
};
|
||||
}
|
||||
|
||||
function calcTooltipPos(rect: Rect, position: TutorialHint['position'], tooltipW = 300, tooltipH = 160): TooltipPos {
|
||||
|
||||
switch (position) {
|
||||
case 'bottom':
|
||||
return {
|
||||
top: Math.min(rect.top + rect.height + PADDING, window.innerHeight - tooltipH - 8),
|
||||
left: Math.min(Math.max(rect.left + rect.width / 2 - tooltipW / 2, 8), window.innerWidth - tooltipW - 8),
|
||||
};
|
||||
case 'top':
|
||||
return {
|
||||
top: Math.max(rect.top - tooltipH - PADDING, 8),
|
||||
left: Math.min(Math.max(rect.left + rect.width / 2 - tooltipW / 2, 8), window.innerWidth - tooltipW - 8),
|
||||
};
|
||||
case 'right':
|
||||
return {
|
||||
top: Math.min(Math.max(rect.top + rect.height / 2 - tooltipH / 2, 8), window.innerHeight - tooltipH - 8),
|
||||
left: Math.min(rect.left + rect.width + PADDING, window.innerWidth - tooltipW - 8),
|
||||
};
|
||||
case 'left':
|
||||
return {
|
||||
top: Math.min(Math.max(rect.top + rect.height / 2 - tooltipH / 2, 8), window.innerHeight - tooltipH - 8),
|
||||
left: Math.max(rect.left - tooltipW - PADDING, 8),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const TutorialOverlay: React.FC<TutorialOverlayProps> = ({
|
||||
hints,
|
||||
currentIndex,
|
||||
onNext,
|
||||
onPrev,
|
||||
onSkip,
|
||||
groupProgress,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [targetRect, setTargetRect] = useState<Rect | null>(null);
|
||||
const tooltipRef = React.useRef<HTMLDivElement>(null);
|
||||
const [tooltipSize, setTooltipSize] = useState({ w: 300, h: 160 });
|
||||
|
||||
const hint = hints[currentIndex];
|
||||
const isLast = currentIndex === hints.length - 1;
|
||||
// 그룹이 있으면 그룹의 마지막 키 + 마지막 힌트일 때만 완료, 그룹 없으면 기존대로
|
||||
const isFinish = isLast && (groupProgress ? groupProgress.isLastKeyInGroup : true);
|
||||
|
||||
const updateRect = useCallback(() => {
|
||||
if (!hint) return;
|
||||
setTargetRect(getTargetRect(hint.targetSelector));
|
||||
}, [hint]);
|
||||
|
||||
useEffect(() => {
|
||||
updateRect();
|
||||
window.addEventListener('resize', updateRect);
|
||||
window.addEventListener('scroll', updateRect, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateRect);
|
||||
window.removeEventListener('scroll', updateRect, true);
|
||||
};
|
||||
}, [updateRect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hint) return;
|
||||
let retryTimer: number | undefined;
|
||||
let rectTimer: number | undefined;
|
||||
let cleanupTarget: (() => void) | undefined;
|
||||
|
||||
const bindToTarget = (el: HTMLElement) => {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
rectTimer = window.setTimeout(updateRect, 200);
|
||||
|
||||
const shouldClickAdvance = hint.clickToAdvance !== false;
|
||||
if (shouldClickAdvance) {
|
||||
el.style.cursor = 'pointer';
|
||||
el.addEventListener('click', onNext);
|
||||
cleanupTarget = () => {
|
||||
el.style.cursor = '';
|
||||
el.removeEventListener('click', onNext);
|
||||
};
|
||||
} else {
|
||||
cleanupTarget = () => {};
|
||||
}
|
||||
};
|
||||
|
||||
const tryBind = (): boolean => {
|
||||
const els = Array.from(document.querySelectorAll(hint.targetSelector));
|
||||
const el = (els.find(e => {
|
||||
const r = (e as HTMLElement).getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
}) ?? els[0]) as HTMLElement | null;
|
||||
if (!el) return false;
|
||||
bindToTarget(el);
|
||||
return true;
|
||||
};
|
||||
|
||||
if (!tryBind()) {
|
||||
retryTimer = window.setInterval(() => {
|
||||
if (tryBind() && retryTimer) {
|
||||
window.clearInterval(retryTimer);
|
||||
retryTimer = undefined;
|
||||
}
|
||||
}, 80);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (retryTimer) window.clearInterval(retryTimer);
|
||||
if (rectTimer) window.clearTimeout(rectTimer);
|
||||
cleanupTarget?.();
|
||||
};
|
||||
}, [hint, updateRect, onNext]);
|
||||
|
||||
// 툴팁 DOM 크기 측정 — 힌트가 바뀔 때만 재측정
|
||||
useEffect(() => {
|
||||
const el = tooltipRef.current;
|
||||
if (!el) return;
|
||||
const { offsetWidth, offsetHeight } = el;
|
||||
if (offsetWidth && offsetHeight) {
|
||||
setTooltipSize(prev =>
|
||||
prev.w === offsetWidth && prev.h === offsetHeight
|
||||
? prev
|
||||
: { w: offsetWidth, h: offsetHeight }
|
||||
);
|
||||
}
|
||||
}, [currentIndex, hints]);
|
||||
|
||||
if (!hint) return null;
|
||||
|
||||
const isTargetVisible = targetRect
|
||||
? targetRect.top + 60 < window.innerHeight && targetRect.bottom > 60
|
||||
: true;
|
||||
|
||||
if (!isTargetVisible) return null;
|
||||
|
||||
const spotlightPadding = hint.spotlightPadding ?? PADDING;
|
||||
const spotlightRect = (targetRect && !hint.noSpotlight) ? getSpotlightRect(targetRect, spotlightPadding, hint.spotlightPaddingOverride) : null;
|
||||
|
||||
// 툴팁은 spotlightRect 기준으로 배치해야 스포트라이트와 겹치지 않음
|
||||
const tooltipPos: TooltipPos = (spotlightRect ?? targetRect)
|
||||
? calcTooltipPos((spotlightRect ?? targetRect)!, hint.position, tooltipSize.w, tooltipSize.h)
|
||||
: {
|
||||
top: window.innerHeight / 2 - tooltipSize.h / 2,
|
||||
left: window.innerWidth / 2 - tooltipSize.w / 2,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tutorial-overlay-root">
|
||||
{spotlightRect ? (
|
||||
<>
|
||||
<div
|
||||
className="tutorial-overlay-blocker"
|
||||
style={{ top: 0, left: 0, right: 0, height: spotlightRect.top }}
|
||||
/>
|
||||
<div
|
||||
className="tutorial-overlay-blocker"
|
||||
style={{
|
||||
top: spotlightRect.top,
|
||||
left: 0,
|
||||
width: spotlightRect.left,
|
||||
height: spotlightRect.height,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="tutorial-overlay-blocker"
|
||||
style={{
|
||||
top: spotlightRect.top,
|
||||
left: spotlightRect.left + spotlightRect.width,
|
||||
right: 0,
|
||||
height: spotlightRect.height,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="tutorial-overlay-blocker"
|
||||
style={{
|
||||
top: spotlightRect.top + spotlightRect.height,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="tutorial-spotlight-ring"
|
||||
style={{
|
||||
top: spotlightRect.top,
|
||||
left: spotlightRect.left,
|
||||
width: spotlightRect.width,
|
||||
height: spotlightRect.height,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className={`tutorial-tooltip${hint.variant === 'bubble' ? ` tutorial-tooltip-bubble tutorial-tooltip-bubble--${hint.position}` : ''}`}
|
||||
style={{ top: tooltipPos.top, left: tooltipPos.left }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p className="tutorial-tooltip-title">{t(hint.titleKey)}</p>
|
||||
<p className="tutorial-tooltip-desc">{t(hint.descriptionKey)}</p>
|
||||
{hint.noteKey && (
|
||||
<p className="tutorial-tooltip-note">{t(hint.noteKey)}</p>
|
||||
)}
|
||||
|
||||
<div className="tutorial-tooltip-footer">
|
||||
<span className="tutorial-tooltip-counter">
|
||||
{groupProgress ? groupProgress.groupOffset + 1 : currentIndex + 1} / {groupProgress ? groupProgress.groupTotal : hints.length}
|
||||
</span>
|
||||
<div className="tutorial-tooltip-actions">
|
||||
<button className="tutorial-btn-skip" onClick={onSkip}>
|
||||
{t('tutorial.skip')}
|
||||
</button>
|
||||
{currentIndex > 0 && (
|
||||
<button className="tutorial-btn-prev" onClick={onPrev}>
|
||||
{t('tutorial.prev')}
|
||||
</button>
|
||||
)}
|
||||
{(hint.clickToAdvance !== true || isFinish) && (
|
||||
<button className="tutorial-btn-next" onClick={onNext}>
|
||||
{isFinish ? t('tutorial.finish') : t('tutorial.next')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TutorialOverlay;
|
||||
|
||||
interface TutorialRestartPopupProps {
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const TutorialRestartPopup: React.FC<TutorialRestartPopupProps> = ({ onConfirm, onCancel }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="tutorial-restart-backdrop">
|
||||
<div className="tutorial-restart-popup" onClick={(e) => e.stopPropagation()}>
|
||||
<p className="tutorial-restart-title">{t('tutorial.restart.title')}</p>
|
||||
<p className="tutorial-restart-desc">{t('tutorial.restart.desc')}</p>
|
||||
<div className="tutorial-restart-actions">
|
||||
<button className="tutorial-restart-cancel" onClick={onCancel}>
|
||||
{t('tutorial.restart.cancel')}
|
||||
</button>
|
||||
<button className="tutorial-restart-confirm" onClick={onConfirm}>
|
||||
{t('tutorial.restart.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,377 +0,0 @@
|
||||
export interface TutorialHint {
|
||||
targetSelector: string;
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
position: 'top' | 'bottom' | 'left' | 'right';
|
||||
noteKey?: string;
|
||||
variant?: 'bubble';
|
||||
clickToAdvance?: boolean;
|
||||
advanceSelector?: string;
|
||||
noSpotlight?: boolean;
|
||||
spotlightPadding?: number;
|
||||
spotlightPaddingOverride?: { top?: number; right?: number; bottom?: number; left?: number };
|
||||
}
|
||||
|
||||
export interface TutorialStepDef {
|
||||
key: string;
|
||||
hints: TutorialHint[];
|
||||
}
|
||||
|
||||
export const TUTORIAL_KEYS = {
|
||||
LANDING: 'landing',
|
||||
ANALYSIS: 'analysis',
|
||||
ASSET: 'asset',
|
||||
SOUND: 'sound',
|
||||
SOUND_LYRICS: 'soundLyrics',
|
||||
SOUND_AUDIO: 'soundAudio',
|
||||
GENERATING:'generating',
|
||||
COMPLETION: 'completion',
|
||||
MY_INFO: 'myInfo',
|
||||
ADO2_CONTENTS: 'ado2Contents',
|
||||
UPLOAD_MODAL: 'uploadModal',
|
||||
UPLOAD_FORM: 'uploadForm',
|
||||
DASHBOARD: 'dashboard',
|
||||
CONTENT_CALENDAR: 'contentCalendar',
|
||||
FEEDBACK: 'feedback',
|
||||
} as const;
|
||||
|
||||
// 같은 페이지에 속하는 튜토리얼 키 그룹 — 진행 카운터를 합산해서 표시
|
||||
export const TUTORIAL_PAGE_GROUPS: string[][] = [
|
||||
[TUTORIAL_KEYS.SOUND, TUTORIAL_KEYS.SOUND_LYRICS, TUTORIAL_KEYS.SOUND_AUDIO],
|
||||
[TUTORIAL_KEYS.GENERATING, TUTORIAL_KEYS.COMPLETION],
|
||||
[TUTORIAL_KEYS.UPLOAD_MODAL, TUTORIAL_KEYS.UPLOAD_FORM],
|
||||
];
|
||||
|
||||
export const tutorialSteps: TutorialStepDef[] = [
|
||||
{
|
||||
key: TUTORIAL_KEYS.LANDING,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.hero-input-wrapper',
|
||||
titleKey: 'tutorial.landing.field.title',
|
||||
descriptionKey: 'tutorial.landing.field.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
noSpotlight: true,
|
||||
variant: 'bubble',
|
||||
},
|
||||
{
|
||||
targetSelector: '.hero-manual-card-title',
|
||||
titleKey: 'tutorial.landing.manual.title',
|
||||
descriptionKey: 'tutorial.landing.manual.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
noSpotlight: true,
|
||||
variant: 'bubble',
|
||||
},
|
||||
{
|
||||
targetSelector: '.hero-button',
|
||||
titleKey: 'tutorial.landing.button.title',
|
||||
descriptionKey: 'tutorial.landing.button.desc',
|
||||
position: 'bottom',
|
||||
clickToAdvance: true,
|
||||
noSpotlight: true,
|
||||
variant: 'bubble',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.ASSET,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.asset-column.asset-column-left',
|
||||
titleKey: 'tutorial.asset.image.title',
|
||||
descriptionKey: 'tutorial.asset.image.desc',
|
||||
position: 'left',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.asset-upload-zone, .asset-mobile-upload-btn',
|
||||
titleKey: 'tutorial.asset.upload.title',
|
||||
descriptionKey: 'tutorial.asset.upload.desc',
|
||||
position: 'bottom',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.asset-ratio-section',
|
||||
titleKey: 'tutorial.asset.ratio.title',
|
||||
descriptionKey: 'tutorial.asset.ratio.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.asset-next-button',
|
||||
titleKey: 'tutorial.asset.next.title',
|
||||
descriptionKey: 'tutorial.asset.next.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.SOUND,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.genre-grid',
|
||||
titleKey: 'tutorial.sound.genre.title',
|
||||
descriptionKey: 'tutorial.sound.genre.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.language-grid',
|
||||
titleKey: 'tutorial.sound.language.title',
|
||||
descriptionKey: 'tutorial.sound.language.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.btn-generate-sound',
|
||||
titleKey: 'tutorial.sound.generate.title',
|
||||
descriptionKey: 'tutorial.sound.generate.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.SOUND_LYRICS,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.lyrics-display',
|
||||
titleKey: 'tutorial.sound.lyrics.title',
|
||||
descriptionKey: 'tutorial.sound.lyrics.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.status-message-new',
|
||||
titleKey: 'tutorial.sound.lyricsWait.title',
|
||||
descriptionKey: 'tutorial.sound.lyricsWait.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.SOUND_AUDIO,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.audio-player',
|
||||
titleKey: 'tutorial.sound.audioPlayer.title',
|
||||
descriptionKey: 'tutorial.sound.audioPlayer.desc',
|
||||
position: 'bottom',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.btn-video-generate',
|
||||
titleKey: 'tutorial.sound.video.title',
|
||||
descriptionKey: 'tutorial.sound.video.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.MY_INFO,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.youtube-connect-section',
|
||||
titleKey: 'tutorial.myInfo.myInfo.title',
|
||||
descriptionKey: 'tutorial.myInfo.myInfo.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.myinfo-social-btn',
|
||||
titleKey: 'tutorial.myInfo.connect.title',
|
||||
descriptionKey: 'tutorial.myInfo.connect.desc',
|
||||
noteKey: 'tutorial.myInfo.connect.note',
|
||||
position: 'top',
|
||||
clickToAdvance: true,
|
||||
},
|
||||
{
|
||||
targetSelector: '.myinfo-connected-accounts',
|
||||
titleKey: 'tutorial.myInfo.connected.title',
|
||||
descriptionKey: 'tutorial.myInfo.connected.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '#sidebar-ado2-contents',
|
||||
titleKey: 'tutorial.myInfo.ado2.title',
|
||||
descriptionKey: 'tutorial.myInfo.ado2.desc',
|
||||
position: 'right',
|
||||
clickToAdvance: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.ADO2_CONTENTS,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.ado2-content-card',
|
||||
titleKey: 'tutorial.ado2.list.title',
|
||||
descriptionKey: 'tutorial.ado2.list.desc',
|
||||
position: 'right',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.content-upload-btn',
|
||||
titleKey: 'tutorial.ado2.download.title',
|
||||
descriptionKey: 'tutorial.ado2.download.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.content-delete-btn',
|
||||
titleKey: 'tutorial.ado2.delete.title',
|
||||
descriptionKey: 'tutorial.ado2.delete.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.content-download-btn',
|
||||
titleKey: 'tutorial.ado2.upload.title',
|
||||
descriptionKey: 'tutorial.ado2.upload.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.GENERATING,
|
||||
hints:[
|
||||
{
|
||||
targetSelector: '.comp2-info-section',
|
||||
titleKey: 'tutorial.completion.contentInfo.title',
|
||||
descriptionKey: 'tutorial.completion.contentInfo.desc',
|
||||
position: 'left',
|
||||
},
|
||||
{
|
||||
targetSelector: '.comp2-video-section',
|
||||
titleKey: 'tutorial.completion.generating.title',
|
||||
descriptionKey: 'tutorial.completion.generating.desc',
|
||||
position: 'top',
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.COMPLETION,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.comp2-video-section',
|
||||
titleKey: 'tutorial.completion.completion.title',
|
||||
descriptionKey: 'tutorial.completion.completion.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '#sidebar-my-info',
|
||||
titleKey: 'tutorial.completion.myInfo.title',
|
||||
descriptionKey: 'tutorial.completion.myInfo.desc',
|
||||
position: 'right',
|
||||
clickToAdvance: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.UPLOAD_MODAL,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.social-posting-content',
|
||||
titleKey: 'tutorial.upload.seo.title',
|
||||
descriptionKey: 'tutorial.upload.seo.desc',
|
||||
position: 'right',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.UPLOAD_FORM,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.social-posting-form',
|
||||
titleKey: 'tutorial.upload.required.title',
|
||||
descriptionKey: 'tutorial.upload.required.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.social-posting-radio-group',
|
||||
titleKey: 'tutorial.upload.schedule.title',
|
||||
descriptionKey: 'tutorial.upload.schedule.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.social-posting-btn:not(.cancel)',
|
||||
titleKey: 'tutorial.upload.submit.title',
|
||||
descriptionKey: 'tutorial.upload.submit.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.DASHBOARD,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.stats-grid-8',
|
||||
titleKey: 'tutorial.dashboard.metrics.title',
|
||||
descriptionKey: 'tutorial.dashboard.metrics.desc',
|
||||
position: 'bottom',
|
||||
},
|
||||
{
|
||||
targetSelector: '.yoy-chart-card',
|
||||
titleKey: 'tutorial.dashboard.chart.title',
|
||||
descriptionKey: 'tutorial.dashboard.chart.desc',
|
||||
position: 'top',
|
||||
},
|
||||
{
|
||||
targetSelector: '.tutorial-center-anchor',
|
||||
titleKey: 'tutorial.dashboard.more.title',
|
||||
descriptionKey: 'tutorial.dashboard.more.desc',
|
||||
position: 'bottom',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.CONTENT_CALENDAR,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.calendar-grid-area',
|
||||
titleKey: 'tutorial.contentCalendar.grid.title',
|
||||
descriptionKey: 'tutorial.contentCalendar.grid.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.calendar-side-panel',
|
||||
titleKey: 'tutorial.contentCalendar.panel.title',
|
||||
descriptionKey: 'tutorial.contentCalendar.panel.desc',
|
||||
position: 'left',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.FEEDBACK,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.tutorial-center-anchor',
|
||||
titleKey: 'tutorial.feedback.complete.title',
|
||||
descriptionKey: 'tutorial.feedback.complete.desc',
|
||||
position: 'bottom',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.sidebar-inquiry-btn',
|
||||
titleKey: 'tutorial.feedback.title',
|
||||
descriptionKey: 'tutorial.feedback.desc',
|
||||
position: 'right',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@ -1,220 +0,0 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { tutorialSteps, TutorialHint, TUTORIAL_PAGE_GROUPS } from './tutorialSteps';
|
||||
|
||||
// 현재 키가 속한 그룹의 전체 힌트 수와 현재까지의 offset 반환
|
||||
function getGroupProgress(key: string, currentIndex: number): { groupTotal: number; groupOffset: number; isLastKeyInGroup: boolean } | null {
|
||||
const group = TUTORIAL_PAGE_GROUPS.find(g => g.includes(key));
|
||||
if (!group) return null;
|
||||
let offset = 0;
|
||||
let total = 0;
|
||||
for (const k of group) {
|
||||
const step = tutorialSteps.find(s => s.key === k);
|
||||
const count = step?.hints.length ?? 0;
|
||||
if (k === key) offset = total;
|
||||
total += count;
|
||||
}
|
||||
const isLastKeyInGroup = group[group.length - 1] === key;
|
||||
return { groupTotal: total, groupOffset: offset + currentIndex, isLastKeyInGroup };
|
||||
}
|
||||
|
||||
const SEEN_KEY = 'ado2_tutorial_seen';
|
||||
const PROGRESS_KEY = 'ado2_tutorial_progress';
|
||||
const ENABLED_KEY = 'ado2_tutorial_enabled';
|
||||
|
||||
// 전역 단일 활성 튜토리얼 관리 — 새 튜토리얼 시작 시 이전 것을 skip 처리
|
||||
let globalSkip: (() => void) | null = null;
|
||||
|
||||
function getSeenKeys(): string[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(SEEN_KEY) || '[]');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function markSeen(key: string) {
|
||||
const seen = getSeenKeys();
|
||||
if (!seen.includes(key)) {
|
||||
localStorage.setItem(SEEN_KEY, JSON.stringify([...seen, key]));
|
||||
}
|
||||
clearProgress(key);
|
||||
}
|
||||
|
||||
function saveProgress(key: string, index: number) {
|
||||
try {
|
||||
const progress = JSON.parse(localStorage.getItem(PROGRESS_KEY) || '{}');
|
||||
progress[key] = index;
|
||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function loadProgress(key: string): number {
|
||||
try {
|
||||
const progress = JSON.parse(localStorage.getItem(PROGRESS_KEY) || '{}');
|
||||
return progress[key] ?? 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function clearProgress(key: string) {
|
||||
try {
|
||||
const progress = JSON.parse(localStorage.getItem(PROGRESS_KEY) || '{}');
|
||||
delete progress[key];
|
||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
interface UseTutorialReturn {
|
||||
isActive: boolean;
|
||||
isEnabled: boolean;
|
||||
isRestartPopupVisible: boolean;
|
||||
currentHintIndex: number;
|
||||
hints: TutorialHint[];
|
||||
tutorialKey: string | null;
|
||||
groupProgress: { groupTotal: number; groupOffset: number; isLastKeyInGroup: boolean } | null;
|
||||
startTutorial: (key: string, onComplete?: () => void, forceFromStart?: boolean) => void;
|
||||
nextHint: () => void;
|
||||
prevHint: () => void;
|
||||
skipTutorial: () => void;
|
||||
toggleTutorial: (currentKey: string | null) => void;
|
||||
showRestartPopup: (key: string) => void;
|
||||
confirmRestart: () => void;
|
||||
cancelRestart: () => void;
|
||||
hasSeen: (key: string) => boolean;
|
||||
}
|
||||
|
||||
export function useTutorial(): UseTutorialReturn {
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [isEnabled, setIsEnabled] = useState(() => localStorage.getItem(ENABLED_KEY) !== 'false');
|
||||
const [isRestartPopupVisible, setIsRestartPopupVisible] = useState(false);
|
||||
const [pendingRestartKey, setPendingRestartKey] = useState<string | null>(null);
|
||||
const [currentHintIndex, setCurrentHintIndex] = useState(0);
|
||||
const [hints, setHints] = useState<TutorialHint[]>([]);
|
||||
const [tutorialKey, setTutorialKey] = useState<string | null>(null);
|
||||
const onCompleteRef = React.useRef<(() => void) | undefined>(undefined);
|
||||
|
||||
const startTutorial = useCallback((key: string, onComplete?: () => void, forceFromStart?: boolean) => {
|
||||
if (localStorage.getItem(ENABLED_KEY) === 'false') return;
|
||||
const step = tutorialSteps.find(s => s.key === key);
|
||||
if (!step || step.hints.length === 0) return;
|
||||
// 다른 인스턴스에서 활성화된 튜토리얼이 있으면 skip 처리
|
||||
globalSkip?.();
|
||||
const savedIndex = forceFromStart ? 0 : loadProgress(key);
|
||||
const resumeIndex = savedIndex < step.hints.length ? savedIndex : 0;
|
||||
onCompleteRef.current = onComplete;
|
||||
setHints(step.hints);
|
||||
setTutorialKey(key);
|
||||
setCurrentHintIndex(resumeIndex);
|
||||
setIsActive(true);
|
||||
// 이 인스턴스의 skip을 전역에 등록
|
||||
globalSkip = () => {
|
||||
if (key) saveProgress(key, resumeIndex);
|
||||
setIsActive(false);
|
||||
setCurrentHintIndex(0);
|
||||
globalSkip = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const nextHint = useCallback(() => {
|
||||
setCurrentHintIndex(prev => {
|
||||
if (prev < hints.length - 1) {
|
||||
const next = prev + 1;
|
||||
if (tutorialKey) saveProgress(tutorialKey, next);
|
||||
return next;
|
||||
}
|
||||
// 마지막 힌트 완료 → seen 기록 + 진행 상태 삭제
|
||||
setIsActive(false);
|
||||
if (tutorialKey) markSeen(tutorialKey);
|
||||
globalSkip = null; // 완료된 튜토리얼은 globalSkip 해제
|
||||
onCompleteRef.current?.();
|
||||
onCompleteRef.current = undefined;
|
||||
return 0;
|
||||
});
|
||||
}, [hints.length, tutorialKey]);
|
||||
|
||||
const prevHint = useCallback(() => {
|
||||
setCurrentHintIndex(prev => Math.max(0, prev - 1));
|
||||
}, []);
|
||||
|
||||
// 건너뛰기: 현재 진행 인덱스 저장 후 오버레이 닫기 → 다음 방문 시 이어서 표시
|
||||
const skipTutorial = useCallback(() => {
|
||||
if (tutorialKey) saveProgress(tutorialKey, currentHintIndex);
|
||||
setIsActive(false);
|
||||
setCurrentHintIndex(0);
|
||||
}, [tutorialKey, currentHintIndex]);
|
||||
|
||||
const toggleTutorial = useCallback((currentKey: string | null) => {
|
||||
if (isEnabled) {
|
||||
// off: 튜토리얼 중단 + 비활성화
|
||||
setIsActive(false);
|
||||
setIsEnabled(false);
|
||||
localStorage.setItem(ENABLED_KEY, 'false');
|
||||
} else {
|
||||
// on: seen/progress 초기화 + 현재 화면 튜토리얼 시작
|
||||
localStorage.removeItem(SEEN_KEY);
|
||||
localStorage.removeItem(PROGRESS_KEY);
|
||||
localStorage.setItem(ENABLED_KEY, 'true');
|
||||
setIsEnabled(true);
|
||||
if (currentKey) {
|
||||
startTutorial(currentKey, undefined, true);
|
||||
}
|
||||
}
|
||||
}, [isEnabled, startTutorial]);
|
||||
|
||||
// 튜토리얼 다시 보기: 팝업 표시만
|
||||
const showRestartPopup = useCallback((key: string) => {
|
||||
setPendingRestartKey(key);
|
||||
setIsRestartPopupVisible(true);
|
||||
}, []);
|
||||
|
||||
// 팝업에서 확인 → seen/progress 초기화 후 튜토리얼 시작
|
||||
const confirmRestart = useCallback(() => {
|
||||
setIsRestartPopupVisible(false);
|
||||
if (pendingRestartKey) {
|
||||
localStorage.removeItem(SEEN_KEY);
|
||||
localStorage.removeItem(PROGRESS_KEY);
|
||||
startTutorial(pendingRestartKey, undefined, true);
|
||||
}
|
||||
setPendingRestartKey(null);
|
||||
}, [pendingRestartKey, startTutorial]);
|
||||
|
||||
// 팝업에서 취소
|
||||
const cancelRestart = useCallback(() => {
|
||||
setIsRestartPopupVisible(false);
|
||||
setPendingRestartKey(null);
|
||||
}, []);
|
||||
|
||||
const hasSeen = useCallback((key: string) => {
|
||||
return getSeenKeys().includes(key);
|
||||
}, []);
|
||||
|
||||
// 영상 보유 사용자 자동 off 이벤트 수신
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setIsActive(false);
|
||||
setIsEnabled(false);
|
||||
};
|
||||
window.addEventListener('ado2-tutorial-auto-disable', handler);
|
||||
return () => window.removeEventListener('ado2-tutorial-auto-disable', handler);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isActive,
|
||||
isEnabled,
|
||||
isRestartPopupVisible,
|
||||
currentHintIndex,
|
||||
hints,
|
||||
tutorialKey,
|
||||
groupProgress: tutorialKey ? getGroupProgress(tutorialKey, currentHintIndex) : null,
|
||||
startTutorial,
|
||||
nextHint,
|
||||
prevHint,
|
||||
skipTutorial,
|
||||
toggleTutorial,
|
||||
showRestartPopup,
|
||||
confirmRestart,
|
||||
cancelRestart,
|
||||
hasSeen,
|
||||
};
|
||||
}
|
||||
@ -21,83 +21,10 @@
|
||||
"credits": "Credits left: {{count}}",
|
||||
"loggingOut": "Logging out...",
|
||||
"logout": "Log Out",
|
||||
"tutorialRestart": "Restart Tutorial",
|
||||
"tutorial": "Tutorial",
|
||||
"tutorialOn": "Enable Tutorial",
|
||||
"tutorialOff": "Disable Tutorial",
|
||||
"login": "Log In",
|
||||
"settings": "Settings",
|
||||
"inquiry": "Feedback"
|
||||
},
|
||||
"tutorial": {
|
||||
"skip": "Skip",
|
||||
"next": "Next",
|
||||
"prev": "Back",
|
||||
"finish": "Done",
|
||||
"landing": {
|
||||
"intro": { "title": "Welcome to ADO2 Tutorial", "desc": "We'll guide you through ADO2 step by step." },
|
||||
"field": { "title": "Enter Search Term", "desc": "Paste a Naver Maps share URL,\nor type a business name and select from the list." },
|
||||
"manual": { "title": "Direct Input", "desc": "You can also enter the business name and address manually to start analysis." },
|
||||
"button": { "title": "Start Brand Analysis", "desc": "Click the button to let AI start analyzing your brand." }
|
||||
},
|
||||
"asset": {
|
||||
"image": { "title": "Image List", "desc": "Photos from Naver Place. Tap 'Show more' to see the rest, or X to remove any." },
|
||||
"upload": { "title": "Add Images", "desc": "You can freely add more images." },
|
||||
"ratio": { "title": "Select Video Ratio", "desc": "Choose the ratio for the video to be generated." },
|
||||
"next": { "title": "Next Step", "desc": "Proceed to the next step when ready." }
|
||||
},
|
||||
"sound": {
|
||||
"genre": { "title": "Select Genre", "desc": "Pick a music genre that fits your brand.", "note": "Background music is coming soon." },
|
||||
"language": { "title": "Select Language", "desc": "You can choose the language for the sound.\nWant to continue with Korean?" },
|
||||
"generate": { "title": "Generate Sound", "desc": "Click the button and AI will generate lyrics and music." },
|
||||
"lyrics": { "title": "Lyrics Complete", "desc": "AI wrote lyrics in your selected language.\nCheck the generated lyrics." },
|
||||
"lyricsWait": { "title": "Generating Music", "desc": "AI is composing music based on the lyrics.\nPlease wait a moment." },
|
||||
"audioPlayer": { "title": "Preview the Music", "desc": "Music generation is complete.\nPress play to listen to the generated music." },
|
||||
"video": { "title": "Generate Video", "desc": "Click the button to start generating your video." }
|
||||
},
|
||||
"completion": {
|
||||
"contentInfo": { "title": "Content Info", "desc": "Check the file name, genre, resolution, and lyrics of the generated content." },
|
||||
"generating": { "title": "Generating Video", "desc": "AI is creating your video.\nPlease wait a moment." },
|
||||
"completion": { "title": "Video Complete!", "desc": "Your video is ready. Want to take a look?" },
|
||||
"myInfo": { "title": "Connect Social Account", "desc": "To upload your video to YouTube, connect your social account in My Info. Click to go there." }
|
||||
},
|
||||
"myInfo": {
|
||||
"myInfo": { "title": "My Info", "desc": "In My Info, you can manage your social connections and view connected accounts." },
|
||||
"connect": { "title": "Connect Now", "desc": "Click the YouTube connect button to go to the connection page.", "note": "Instagram connection is coming soon." },
|
||||
"connected": { "title": "Connected Accounts", "desc": "Your linked social accounts appear here.\nCheck after connecting." },
|
||||
"ado2": { "title": "ADO2 Contents", "desc": "You can now upload the generated video.\nClick to navigate." }
|
||||
},
|
||||
"ado2": {
|
||||
"list": { "title": "Generated Videos", "desc": "View all AI-created videos here." },
|
||||
"download": { "title": "Download", "desc": "Download the video to your device." },
|
||||
"delete": { "title": "Delete", "desc": "Remove videos you no longer need." },
|
||||
"upload": { "title": "Upload to Social Media", "desc": "Select a video and upload it to social media." }
|
||||
},
|
||||
"upload": {
|
||||
"seo": { "title": "Title & Description", "desc": "AI is generating the title and description for your video. Please wait a moment." },
|
||||
"required": { "title": "Required Fields", "desc": "Fields marked with * are required.\nPlease check them before uploading." },
|
||||
"schedule": { "title": "Schedule Upload", "desc": "Post now or schedule for a specific time." },
|
||||
"submit": { "title": "Start Upload", "desc": "Click the Post button to start uploading." }
|
||||
},
|
||||
"dashboard": {
|
||||
"metrics": { "title": "Key Metrics", "desc": "Check views, subscribers, and other stats for content uploaded via ADO2." },
|
||||
"chart": { "title": "Growth Chart", "desc": "Track your channel's growth over time." },
|
||||
"more": { "title": "More Analytics", "desc": "Even more statistics are available at a glance on the dashboard." }
|
||||
},
|
||||
"contentCalendar": {
|
||||
"grid": { "title": "Content Calendar", "desc": "View your content schedule by date.\nWhy not select today?" },
|
||||
"panel": { "title": "Content List", "desc": "Check the detailed content schedule here." }
|
||||
},
|
||||
"feedback": {
|
||||
"complete": { "title": "Tutorial Complete 🎉", "desc": "You've completed the full flow from brand analysis to YouTube upload.\nTo replay the tutorial, click the button in the top right." },
|
||||
"title": "Customer Feedback",
|
||||
"desc": "Share any issues or suggestions to help us improve."
|
||||
},
|
||||
"restart": {
|
||||
"title": "Restart Tutorial?",
|
||||
"desc": "The tutorial will restart from the current screen.",
|
||||
"confirm": "Start",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"company": "O2O Inc.",
|
||||
"businessNumber": "Business Registration No. : 620-87-00810 | CEO : Ahn Sungmin",
|
||||
@ -497,10 +424,10 @@
|
||||
},
|
||||
"myInfo": {
|
||||
"title": "My Info",
|
||||
"tabBasic": "Basic Info",
|
||||
"tabPayment": "Payment Info",
|
||||
"tabBusiness": "Social Channel Management",
|
||||
"basicPlaceholder": "Basic information settings are coming soon.",
|
||||
"tabContents": "My Contents",
|
||||
"tabCalendar": "Calendar",
|
||||
"tabPayment": "Payment",
|
||||
"tabBusiness": "SNS Management",
|
||||
"paymentPlaceholder": "Payment information settings are coming soon.",
|
||||
"myBusiness": "My Business",
|
||||
"noBusinessTitle": "No registered business yet",
|
||||
|
||||
@ -21,83 +21,10 @@
|
||||
"credits": "보유 크레딧: {{count}}",
|
||||
"loggingOut": "로그아웃 중...",
|
||||
"logout": "로그아웃",
|
||||
"tutorialRestart": "튜토리얼 다시 보기",
|
||||
"tutorial": "튜토리얼",
|
||||
"tutorialOn": "튜토리얼 켜기",
|
||||
"tutorialOff": "튜토리얼 끄기",
|
||||
"login": "로그인",
|
||||
"settings": "설정",
|
||||
"inquiry": "고객의견"
|
||||
},
|
||||
"tutorial": {
|
||||
"skip": "건너뛰기",
|
||||
"next": "다음",
|
||||
"prev": "이전",
|
||||
"finish": "완료",
|
||||
"landing": {
|
||||
"intro": { "title": "ADO2 튜토리얼 시작", "desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요." },
|
||||
"field": { "title": "입력하기", "desc": "네이버 지도 공유 URL을 붙여넣거나,\n업체명을 입력하면 자동으로 목록에서 선택할 수 있어요." },
|
||||
"manual": { "title": "직접 입력", "desc": "업체명과 주소를 직접 입력해서 분석을 시작할 수도 있어요." },
|
||||
"button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." }
|
||||
},
|
||||
"asset": {
|
||||
"image": { "title": "이미지 목록", "desc": "네이버 Place에서 가져 온 사진이에요. \n더보기를 누르면 나머지 사진도 볼 수 있고 X를 눌러 삭제 할 수 있어요." },
|
||||
"upload": { "title": "이미지 추가", "desc": "이미지를 자유롭게 추가 할 수 있어요." },
|
||||
"ratio": { "title": "영상 비율 선택", "desc": "생성 할 영상의 비율을 선택하세요." },
|
||||
"next": { "title": "다음 단계로", "desc": "설정이 완료되면 다음 단계로 진행하세요." }
|
||||
},
|
||||
"sound": {
|
||||
"genre": { "title": "장르 선택", "desc": "영상에 어울리는 음악 장르를 선택하세요.", "note": "배경음악은 이후 오픈 예정입니다." },
|
||||
"language": { "title": "언어 선택", "desc": "음악의 언어를 선택할 수 있어요. \n이미 선택된 한국어로 진행해볼까요?" },
|
||||
"generate": { "title": "사운드 생성", "desc": "버튼을 클릭하면 AI가 가사와 음악을 생성해요."},
|
||||
"lyrics": { "title": "가사 생성 완료", "desc": "AI가 선택한 언어로 가사를 만들었어요.\n생성된 가사를 확인하세요." },
|
||||
"lyricsWait": { "title": "음악 생성 중", "desc": "가사를 바탕으로 AI가 음악을 만들고 있어요.\n잠시만 기다려 주세요." },
|
||||
"audioPlayer": { "title": "음악 미리 듣기", "desc": "음악 생성이 완료되었어요.\n재생 버튼을 눌러 생성된 음악을 들어보세요." },
|
||||
"video": { "title": "영상 생성", "desc": "버튼을 클릭해서 영상 생성을 시작하세요." }
|
||||
},
|
||||
"completion": {
|
||||
"contentInfo": { "title": "콘텐츠 정보", "desc": "콘텐츠의 파일명, 장르, 규격, 가사를 확인하세요." },
|
||||
"generating": { "title": "영상 제작 중", "desc": "AI가 영상을 만들고 있어요. \n잠시만 기다려 주세요." },
|
||||
"completion": { "title": "영상 완성!", "desc": "영상 제작이 완료되었어요. \n영상을 확인해 볼까요?" },
|
||||
"myInfo": { "title": "소셜 계정 연동", "desc": "영상을 유튜브에 업로드하려면 내 정보에서 소셜 계정을 연동해야 해요. \n클릭해서 이동하세요." }
|
||||
},
|
||||
"myInfo": {
|
||||
"myInfo": { "title": "내 정보", "desc": "내 정보에서는 소셜 연결과 연결된 계정을 확인 할 수 있어요." },
|
||||
"connect": { "title": "연결하기", "desc": "YouTube 연결 버튼을 누르면 연결 페이지로 이동합니다.", "note": "Instagram 연결은 오픈 예정입니다." },
|
||||
"connected": { "title": "연결 계정", "desc": "연결된 소셜 계정 목록이에요. \n연결 후 여기서 확인할 수 있어요." },
|
||||
"ado2": { "title": "ADO2 콘텐츠", "desc": "이제 생성된 영상을 업로드할 수 있어요. \n클릭해서 이동하세요." }
|
||||
},
|
||||
"ado2": {
|
||||
"list": { "title": "생성된 영상 목록", "desc": "ADO2에서 만든 영상들을 확인할 수 있어요." },
|
||||
"download": { "title": "다운로드", "desc": "영상을 다운로드 할 수 있어요." },
|
||||
"delete": { "title": "삭제", "desc": "필요없는 영상을 삭제할 수 있어요." },
|
||||
"upload": { "title": "소셜 업로드", "desc": "선택해서 소셜미디어에 업로드하세요." }
|
||||
},
|
||||
"upload": {
|
||||
"seo": { "title": "제목 및 설명", "desc": "영상의 제목과 설명을 AI가 만들고 있어요. 잠시만 기다려 주세요." },
|
||||
"required": { "title": "필수 항목", "desc": "영상을 업로드 하기 전 *는 필수 항목으로 \n반드시 확인해 주세요." },
|
||||
"schedule": { "title": "업로드 예약", "desc": "지금 게시하거나 원하는 시간에 예약할 수 있어요." },
|
||||
"submit": { "title": "업로드 시작", "desc": "게시 버튼을 눌러 업로드를 시작하세요." }
|
||||
},
|
||||
"dashboard": {
|
||||
"metrics": { "title": "핵심 지표", "desc": "조회수, 구독자 등 ADO2로 업로드한 콘텐츠의 주요 통계를 확인하세요." },
|
||||
"chart": { "title": "성장 추이 차트", "desc": "기간별 성장 추이를 그래프로 확인할 수 있어요." },
|
||||
"more": { "title": "더 많은 통계", "desc": "그 외에도 다양한 통계를 대시보드에서 한눈에 확인할 수 있어요." }
|
||||
},
|
||||
"contentCalendar": {
|
||||
"grid": { "title": "콘텐츠 캘린더", "desc": "날짜별로 콘텐츠 스케줄을 확인할 수 있어요. \n오늘 날짜를 선택해 볼까요?" },
|
||||
"panel": { "title": "콘텐츠 목록", "desc": "자세한 콘텐츠 스케줄을 확인 할수 있어요." }
|
||||
},
|
||||
"feedback": {
|
||||
"complete": { "title": "튜토리얼 완료 🎉", "desc": "유튜브 업로드까지 모든 과정을 완료했어요. \n튜토리얼을 다시보고 싶다면 우측 상단의 버튼을 눌러주세요." },
|
||||
"title": "고객의견",
|
||||
"desc": "서비스 이용 중 불편한 점이나 개선 의견을 보내주세요."
|
||||
},
|
||||
"restart": {
|
||||
"title": "튜토리얼을 다시 시작할까요?",
|
||||
"desc": "현재 화면부터 튜토리얼이 다시 시작됩니다.",
|
||||
"confirm": "시작하기",
|
||||
"cancel": "취소"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"company":"㈜에이아이오투오",
|
||||
"businessNumber": "사업자 등록번호 : 620-87-00810 | 대표 : 안성민",
|
||||
@ -244,7 +171,7 @@
|
||||
"create": {
|
||||
"step1": "STEP 1 · 시나리오 선택",
|
||||
"step2": "STEP 2 · 업장 검색",
|
||||
"searchPlaceholder": "가게 이름 (예: 골목냉면) 또는 네이버 지도 링크",
|
||||
"searchPlaceholder": "업체명 또는 URL을 입력하세요.",
|
||||
"searchButton": "검색",
|
||||
"searching": "검색 중",
|
||||
"placeLoading": "가게를 찾는 중… (몇 초 걸려요)",
|
||||
@ -274,7 +201,9 @@
|
||||
"title": "썰박스가 완성됐어요",
|
||||
"download": "다운로드",
|
||||
"downloading": "다운로드 중...",
|
||||
"loadFailed": "영상을 불러오지 못했어요. 내 콘텐츠에서 확인해주세요."
|
||||
"loadFailed": "영상을 불러오지 못했어요. 내 콘텐츠에서 확인해주세요.",
|
||||
"scenarioLabel": "시나리오",
|
||||
"createdLabel": "생성일시"
|
||||
},
|
||||
"viewer": {
|
||||
"untitled": "이름 없는 콘텐츠"
|
||||
@ -543,10 +472,10 @@
|
||||
},
|
||||
"myInfo": {
|
||||
"title": "내 정보",
|
||||
"tabBasic": "기본 정보",
|
||||
"tabContents": "내 콘텐츠",
|
||||
"tabCalendar": "콘텐츠 캘린더",
|
||||
"tabPayment": "결제 정보",
|
||||
"tabBusiness": "소셜 채널 관리",
|
||||
"basicPlaceholder": "기본 정보 설정 기능이 준비 중입니다.",
|
||||
"paymentPlaceholder": "결제 정보 설정 기능이 준비 중입니다.",
|
||||
"myBusiness": "내 비즈니스",
|
||||
"noBusinessTitle": "아직 등록된 비즈니스가 없어요",
|
||||
|
||||
@ -1,10 +1,7 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React 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;
|
||||
@ -14,19 +11,8 @@ interface AnalysisResultSectionProps {
|
||||
|
||||
const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ 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 || [];
|
||||
@ -37,7 +23,6 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
||||
const sortedSellingPoints = [...sellingPoints].sort((a, b) => b.score - a.score);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bi2-page">
|
||||
{/* Header */}
|
||||
<div className="bi2-header">
|
||||
@ -189,18 +174,6 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tutorial.isActive && (
|
||||
<TutorialOverlay
|
||||
hints={tutorial.hints}
|
||||
currentIndex={tutorial.currentHintIndex}
|
||||
onNext={tutorial.nextHint}
|
||||
onPrev={tutorial.prevHint}
|
||||
onSkip={tutorial.skipTutorial}
|
||||
groupProgress={tutorial.groupProgress}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -2,24 +2,22 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Sidebar from '../../components/Sidebar';
|
||||
import BottomNav from '../../components/BottomNav';
|
||||
import LoginPromptModal from '../../components/LoginPromptModal';
|
||||
import AssetManagementContent from './AssetManagementContent';
|
||||
import SoundStudioContent from './SoundStudioContent';
|
||||
import CompletionContent from './CompletionContent';
|
||||
import DashboardContent from './DashboardContent';
|
||||
import BusinessSettingsContent from './BusinessSettingsContent';
|
||||
import UrlInputContent from './UrlInputContent';
|
||||
import ADO2ContentsPage from './ADO2ContentsPage';
|
||||
import MyContentsPage from './MyContentsPage';
|
||||
import MyInfoContent from './MyInfoContent';
|
||||
import ContentCalendarContent from './ContentCalendarContent';
|
||||
import LoadingSection from '../Analysis/LoadingSection';
|
||||
import AnalysisResultSection from '../Analysis/AnalysisResultSection';
|
||||
import { ImageItem, type ImageListItem, CrawlingResponse, UserMeResponse } from '../../types/api';
|
||||
import { crawlUrl, autocomplete, marketingAnalysis, AutocompleteRequest, getUserMe, getUserCredits, clearTokens, getActiveSsulTask, waitForSsulComplete } from '../../utils/api';
|
||||
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
||||
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
||||
import TutorialOverlay, { TutorialRestartPopup } from '../../components/Tutorial/TutorialOverlay';
|
||||
import { crawlUrl, autocomplete, marketingAnalysis, AutocompleteRequest, getUserMe, getUserCredits, clearTokens, getActiveSsulTask, waitForSsulComplete, isLoggedIn } from '../../utils/api';
|
||||
import WizardStepper from '../../components/WizardStepper';
|
||||
import { NAV, GATED_ITEMS, sanitizeActiveItem, MyInfoTab } from '../../components/navItems';
|
||||
import { useIsMobile } from '../../utils/useIsMobile';
|
||||
|
||||
import { saveSearchHistory } from '../../components/SearchHistory/useSearchHistory';
|
||||
import { Pipeline } from '../../components/PipelineTabs';
|
||||
@ -45,6 +43,9 @@ const ANALYSIS_DATA_KEY = K.ANALYSIS_DATA;
|
||||
*/
|
||||
const PENDING_PIPELINE_KEY = 'castad_pending_pipeline';
|
||||
|
||||
/** 비로그인 게이트 클릭 시 "로그인 후 열어줄 메뉴"를 남기는 1회성 프리셋. 카카오 왕복을 견뎌야 해서 sessionStorage */
|
||||
const LOGIN_NEXT_ITEM_KEY = 'castad_login_next_item';
|
||||
|
||||
/** 썰박스 진행 단계. ADO2 의 wizardStep(-2~3) 과 별도 축이다 */
|
||||
type SsulStep = 'entry' | 'making' | 'result';
|
||||
|
||||
@ -68,7 +69,6 @@ interface BusinessInfo {
|
||||
}
|
||||
|
||||
interface GenerationFlowProps {
|
||||
onHome: () => void;
|
||||
initialActiveItem?: string;
|
||||
initialImageList?: ImageListItem[];
|
||||
businessInfo?: BusinessInfo;
|
||||
@ -84,8 +84,7 @@ interface GenerationFlowProps {
|
||||
// 3: 완료 (Completion)
|
||||
|
||||
const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
onHome,
|
||||
initialActiveItem = '대시보드',
|
||||
initialActiveItem = NAV.HOME,
|
||||
initialImageList = [],
|
||||
businessInfo,
|
||||
initialAnalysisData
|
||||
@ -109,7 +108,20 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const [activeItem, setActiveItem] = useState(savedActiveItem || initialActiveItem);
|
||||
const isGuest = !isLoggedIn();
|
||||
|
||||
/** activeItem 초기화: 로그인 복귀 메뉴(최우선) → 저장된 값 → 초기값, 마지막에 구 이름 마이그레이션+게스트 강등 */
|
||||
const getInitialNav = () => {
|
||||
const loginNext = sessionStorage.getItem(LOGIN_NEXT_ITEM_KEY);
|
||||
if (loginNext && isLoggedIn()) {
|
||||
sessionStorage.removeItem(LOGIN_NEXT_ITEM_KEY);
|
||||
return sanitizeActiveItem(loginNext, isGuest);
|
||||
}
|
||||
return sanitizeActiveItem(savedActiveItem || initialActiveItem, isGuest);
|
||||
};
|
||||
const initialNav = getInitialNav();
|
||||
|
||||
const [activeItem, setActiveItem] = useState(initialNav.item);
|
||||
|
||||
// 초기 위저드 단계 결정
|
||||
const getInitialWizardStep = (): number => {
|
||||
@ -174,10 +186,10 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
const [isAnalysisComplete, setIsAnalysisComplete] = useState(false);
|
||||
const [userInfo, setUserInfo] = useState<UserMeResponse | null>(null);
|
||||
const [credits, setCredits] = useState<number | null>(null);
|
||||
const [myInfoInitialTab, setMyInfoInitialTab] = useState<'basic' | 'payment' | 'business' | undefined>(undefined);
|
||||
const tutorial = useTutorial();
|
||||
const [myInfoInitialTab, setMyInfoInitialTab] = useState<MyInfoTab | undefined>(initialNav.myInfoTab);
|
||||
|
||||
const refreshCredits = useCallback(async () => {
|
||||
if (!isLoggedIn()) return;
|
||||
try {
|
||||
const { credits } = await getUserCredits();
|
||||
setCredits(credits);
|
||||
@ -193,8 +205,9 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
}
|
||||
}, [wizardStep]);
|
||||
|
||||
// 로그인 직후 사용자 정보 + 크레딧 조회
|
||||
// 로그인 직후 사용자 정보 + 크레딧 조회 (비로그인이면 요청 자체를 하지 않는다 — 401 → 강제 리다이렉트 방지)
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) return;
|
||||
const fetchUserInfo = async () => {
|
||||
try {
|
||||
const [data, creditsData] = await Promise.all([getUserMe(), getUserCredits()]);
|
||||
@ -263,7 +276,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
setImageList(prev => [...newImages, ...prev]);
|
||||
};
|
||||
|
||||
// 홈 버튼(로고) 클릭 시 모든 상태 초기화 후 홈으로 이동
|
||||
// 홈 버튼(로고) 클릭 시 모든 상태 초기화 후 ADO2 콘텐츠 갤러리로 이동
|
||||
const handleHome = () => {
|
||||
clearProjectStorage();
|
||||
localStorage.removeItem(ANALYSIS_DATA_KEY);
|
||||
@ -272,7 +285,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
setImageTaskId(null);
|
||||
setAnalysisData(null);
|
||||
setImageList([]);
|
||||
onHome();
|
||||
setActiveItem(NAV.HOME);
|
||||
};
|
||||
|
||||
// 위저드 단계 이동
|
||||
@ -413,39 +426,22 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
localStorage.setItem(ACTIVE_ITEM_KEY, activeItem);
|
||||
}, [activeItem]);
|
||||
|
||||
// wizardStep 변경 시 튜토리얼 트리거 (사이드바 메뉴 화면에서는 제외)
|
||||
const SIDEBAR_ITEMS = ['대시보드', 'ADO2 콘텐츠', '내 정보', '콘텐츠 캘린더'];
|
||||
useEffect(() => {
|
||||
if (tutorial.isActive) tutorial.skipTutorial();
|
||||
if (SIDEBAR_ITEMS.includes(activeItem)) return;
|
||||
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]);
|
||||
const [loginGateOpen, setLoginGateOpen] = useState(false);
|
||||
|
||||
// activeItem 변경 시 튜토리얼 트리거
|
||||
useEffect(() => {
|
||||
if (tutorial.isActive) tutorial.skipTutorial();
|
||||
if (activeItem === '내 정보' && !tutorial.hasSeen(TUTORIAL_KEYS.MY_INFO)) {
|
||||
tutorial.startTutorial(TUTORIAL_KEYS.MY_INFO);
|
||||
} else if (activeItem === '내 콘텐츠' && !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);
|
||||
} else if (activeItem === '콘텐츠 캘린더' && !tutorial.hasSeen(TUTORIAL_KEYS.CONTENT_CALENDAR)) {
|
||||
tutorial.startTutorial(TUTORIAL_KEYS.CONTENT_CALENDAR);
|
||||
/** 내 정보의 특정 탭으로 딥링크 이동. 비로그인이면 로그인 관문을 띄운다 */
|
||||
const goToMyInfoTab = (tab: MyInfoTab) => {
|
||||
if (!isLoggedIn()) {
|
||||
sessionStorage.setItem(LOGIN_NEXT_ITEM_KEY, NAV.MY);
|
||||
setLoginGateOpen(true);
|
||||
return;
|
||||
}
|
||||
}, [activeItem]);
|
||||
setMyInfoInitialTab(tab);
|
||||
setActiveItem(NAV.MY);
|
||||
};
|
||||
|
||||
// 결제 탭으로 바로 이동하는 핸들러 (크레딧 충전하기 버튼용)
|
||||
const handleGoToPayment = () => {
|
||||
setMyInfoInitialTab('payment');
|
||||
setActiveItem('내 정보');
|
||||
goToMyInfoTab('payment');
|
||||
};
|
||||
|
||||
/**
|
||||
@ -520,6 +516,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
* initializeOnNewSession 이 새 탭에서 그것을 지우기 때문에 서버를 물어야 한다.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) return;
|
||||
let alive = true;
|
||||
getActiveSsulTask().then((task) => {
|
||||
if (!alive || !task) return;
|
||||
@ -543,8 +540,13 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
|
||||
// 네비게이션 핸들러 - "새 프로젝트 만들기" 클릭 시 기존 프로젝트 데이터 초기화
|
||||
const handleNavigate = (item: string) => {
|
||||
if (!isLoggedIn() && GATED_ITEMS.includes(item)) {
|
||||
sessionStorage.setItem(LOGIN_NEXT_ITEM_KEY, item);
|
||||
setLoginGateOpen(true);
|
||||
return;
|
||||
}
|
||||
setMyInfoInitialTab(undefined);
|
||||
if (item === '새 프로젝트 만들기') {
|
||||
if (item === NAV.NEW) {
|
||||
// 기존 프로젝트 데이터 초기화
|
||||
clearProjectStorage();
|
||||
localStorage.removeItem(ANALYSIS_DATA_KEY);
|
||||
@ -660,13 +662,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
videoGenerationStatus={videoGenerationStatus}
|
||||
videoGenerationProgress={videoGenerationProgress}
|
||||
onGoToPayment={handleGoToPayment}
|
||||
onStatusChange={(status: string) => {
|
||||
if (status === 'generating_song' && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND_LYRICS)) {
|
||||
setTimeout(() => tutorial.startTutorial(TUTORIAL_KEYS.SOUND_LYRICS), 400);
|
||||
} else if (status === 'complete' && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND_AUDIO)) {
|
||||
setTimeout(() => tutorial.startTutorial(TUTORIAL_KEYS.SOUND_AUDIO), 400);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 3:
|
||||
@ -684,7 +679,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
songTaskId={songTaskId}
|
||||
onVideoStatusChange={setVideoGenerationStatus}
|
||||
onVideoProgressChange={setVideoGenerationProgress}
|
||||
onGoToCalendar={() => handleNavigate('콘텐츠 캘린더')}
|
||||
onGoToCalendar={() => goToMyInfoTab('calendar')}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
@ -694,24 +689,17 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
|
||||
const renderContent = () => {
|
||||
switch (activeItem) {
|
||||
case '대시보드':
|
||||
return <DashboardContent onNavigate={handleNavigate} />;
|
||||
case '비즈니스 설정':
|
||||
return <BusinessSettingsContent />;
|
||||
case 'ADO2 콘텐츠':
|
||||
return <ADO2ContentsPage />;
|
||||
case '내 콘텐츠':
|
||||
case NAV.DASHBOARD:
|
||||
return (
|
||||
<MyContentsPage
|
||||
onBack={() => setActiveItem('새 프로젝트 만들기')}
|
||||
onNavigate={handleNavigate}
|
||||
<DashboardContent
|
||||
onNavigate={(id) => (id === NAV.MY ? goToMyInfoTab('business') : handleNavigate(id))}
|
||||
/>
|
||||
);
|
||||
case '콘텐츠 캘린더':
|
||||
return <ContentCalendarContent onNavigate={handleNavigate} />;
|
||||
case '내 정보':
|
||||
return <MyInfoContent initialTab={myInfoInitialTab} />;
|
||||
case '새 프로젝트 만들기':
|
||||
case NAV.HOME:
|
||||
return <ADO2ContentsPage />;
|
||||
case NAV.MY:
|
||||
return <MyInfoContent initialTab={myInfoInitialTab} onNavigate={handleNavigate} />;
|
||||
case NAV.NEW:
|
||||
// 진입 화면(탭)과 ADO2 로딩(-1)은 스텝퍼 없이 전체 화면으로 표시
|
||||
if (isEntry || (pipeline === 'ado2' && wizardStep === -1)) {
|
||||
return renderWizardContent();
|
||||
@ -752,88 +740,75 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 로딩 화면에서만 Sidebar 숨김
|
||||
// 로딩 화면에서만 Sidebar/BottomNav 숨김
|
||||
const showSidebar = wizardStep !== -1;
|
||||
|
||||
// 브랜드 분석(0)일 때는 전체 페이지 스크롤
|
||||
const isBrandAnalysis = activeItem === '새 프로젝트 만들기' && wizardStep === 0;
|
||||
// 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0), ADO2 콘텐츠, 내 정보
|
||||
const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || activeItem === 'ADO2 콘텐츠' || activeItem === '내 콘텐츠' || activeItem === '내 정보' || activeItem === '콘텐츠 캘린더' || isBrandAnalysis;
|
||||
const isBrandAnalysis = activeItem === NAV.NEW && wizardStep === 0;
|
||||
// 위저드 진행 중에도 탭바를 노출한다. 각 단계의 고정 CTA 는 모바일에서
|
||||
// 탭바 높이만큼 띄우도록 CSS(@media max-width:767px)에서 처리한다.
|
||||
const showBottomNav = showSidebar;
|
||||
// 스크롤이 필요한 페이지: 새 프로젝트 만들기(위저드) 외 전부 + 브랜드 분석(0)
|
||||
const needsScroll = activeItem !== NAV.NEW || isBrandAnalysis;
|
||||
|
||||
// 현재 화면에 맞는 튜토리얼 키 반환 (없으면 null)
|
||||
const getCurrentTutorialKey = (): string | null => {
|
||||
if (activeItem === '내 정보') return TUTORIAL_KEYS.MY_INFO;
|
||||
if (activeItem === '내 콘텐츠') return TUTORIAL_KEYS.ADO2_CONTENTS;
|
||||
if (activeItem === '대시보드') return TUTORIAL_KEYS.DASHBOARD;
|
||||
if (activeItem === '콘텐츠 캘린더') return TUTORIAL_KEYS.CONTENT_CALENDAR;
|
||||
if (activeItem === '새 프로젝트 만들기') {
|
||||
if (wizardStep === 1) return TUTORIAL_KEYS.ASSET;
|
||||
if (wizardStep === 2) return TUTORIAL_KEYS.SOUND;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const tutorialUI = (
|
||||
<>
|
||||
{getCurrentTutorialKey() && (
|
||||
<button
|
||||
className={`tutorial-toggle-fab ${tutorial.isEnabled ? 'active' : ''}`}
|
||||
onClick={() => tutorial.toggleTutorial(getCurrentTutorialKey())}
|
||||
title={tutorial.isEnabled ? t('sidebar.tutorialOff') : t('sidebar.tutorialOn')}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M12 8v4l3 3"/>
|
||||
</svg>
|
||||
<span>{t('sidebar.tutorial')}</span>
|
||||
<span className={`tutorial-toggle-badge ${tutorial.isEnabled ? 'on' : 'off'}`}>
|
||||
{tutorial.isEnabled ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{tutorial.isActive && (
|
||||
<TutorialOverlay
|
||||
hints={tutorial.hints}
|
||||
currentIndex={tutorial.currentHintIndex}
|
||||
onNext={tutorial.nextHint}
|
||||
onPrev={tutorial.prevHint}
|
||||
onSkip={tutorial.skipTutorial}
|
||||
groupProgress={tutorial.groupProgress}
|
||||
const nav = isMobile
|
||||
? (showBottomNav && (
|
||||
<BottomNav
|
||||
activeItem={activeItem}
|
||||
onNavigate={handleNavigate}
|
||||
userInfo={userInfo}
|
||||
onLogout={handleLogout}
|
||||
credits={credits}
|
||||
isGuest={isGuest}
|
||||
onLoginClick={() => setLoginGateOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{tutorial.isRestartPopupVisible && (
|
||||
<TutorialRestartPopup
|
||||
onConfirm={tutorial.confirmRestart}
|
||||
onCancel={tutorial.cancelRestart}
|
||||
))
|
||||
: (showSidebar && (
|
||||
<Sidebar
|
||||
activeItem={activeItem}
|
||||
onNavigate={handleNavigate}
|
||||
onLogoClick={handleHome}
|
||||
userInfo={userInfo}
|
||||
onLogout={handleLogout}
|
||||
credits={credits}
|
||||
isGuest={isGuest}
|
||||
onLoginClick={() => setLoginGateOpen(true)}
|
||||
/>
|
||||
));
|
||||
|
||||
const loginGateModal = loginGateOpen && (
|
||||
<LoginPromptModal
|
||||
onClose={() => {
|
||||
sessionStorage.removeItem(LOGIN_NEXT_ITEM_KEY);
|
||||
setLoginGateOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// 브랜드 분석일 때는 전체 화면 스크롤
|
||||
if (isBrandAnalysis) {
|
||||
return (
|
||||
<div className="analysis-page-wrapper">
|
||||
{showSidebar && (
|
||||
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} credits={credits} tutorialAvailable={!!getCurrentTutorialKey()} tutorialEnabled={tutorial.isEnabled} onToggleTutorial={() => tutorial.toggleTutorial(getCurrentTutorialKey())} />
|
||||
)}
|
||||
<main className="analysis-page-main">
|
||||
{!isMobile && nav}
|
||||
<main className={`analysis-page-main ${showBottomNav ? 'has-bottom-nav' : ''}`}>
|
||||
{renderContent()}
|
||||
</main>
|
||||
{tutorialUI}
|
||||
{isMobile && nav}
|
||||
{loginGateModal}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex w-full bg-[#002224] text-white ${needsScroll ? 'min-h-[100dvh]' : 'h-[100dvh] overflow-hidden'}`}>
|
||||
{showSidebar && (
|
||||
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} credits={credits} tutorialAvailable={!!getCurrentTutorialKey()} tutorialEnabled={tutorial.isEnabled} onToggleTutorial={() => tutorial.toggleTutorial(getCurrentTutorialKey())} />
|
||||
)}
|
||||
{tutorialUI}
|
||||
<div className={`flex-1 relative pl-0 md:pl-0 bg-[#001a1c] ${needsScroll ? 'overflow-auto' : 'h-full overflow-hidden'}`}>
|
||||
{!isMobile && nav}
|
||||
<div className={`flex-1 relative pl-0 md:pl-0 bg-[#001a1c] ${needsScroll ? 'overflow-auto' : 'h-full overflow-hidden'} ${showBottomNav ? 'has-bottom-nav' : ''}`}>
|
||||
{renderContent()}
|
||||
</div>
|
||||
{isMobile && nav}
|
||||
{loginGateModal}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,51 +0,0 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Footer from '../../components/Footer';
|
||||
|
||||
interface DisplaySectionProps {
|
||||
onStartClick?: () => void;
|
||||
}
|
||||
|
||||
const DisplaySection: React.FC<DisplaySectionProps> = ({ onStartClick }) => {
|
||||
const { t } = useTranslation();
|
||||
// YouTube Shorts 영상 ID들
|
||||
const videos = [
|
||||
{ id: 1, videoId: 'M3iuPZ59X1I' },
|
||||
{ id: 2, videoId: 'JxWQxELDHSs' },
|
||||
{ id: 3, videoId: 'c2ZdwhaB7S4' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="display-section">
|
||||
<div className="content-safe-area">
|
||||
{/* Main visual frames container */}
|
||||
<div className="display-frames">
|
||||
{videos.map((video, index) => (
|
||||
<div
|
||||
key={video.id}
|
||||
className={`display-frame ${index === 2 ? 'display-frame-hidden-mobile' : ''}`}
|
||||
>
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${video.videoId}?autoplay=1&mute=1&loop=1&playlist=${video.videoId}&controls=0&showinfo=0&rel=0&modestbranding=1&playsinline=1`}
|
||||
title={`YouTube Shorts ${video.id}`}
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
className="display-video"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
<button onClick={onStartClick} className="display-button">
|
||||
{t('landing.display.startButton')}
|
||||
</button>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DisplaySection;
|
||||
@ -1,202 +0,0 @@
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AutocompleteRequest, isLoggedIn } from '../../utils/api';
|
||||
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
||||
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
||||
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
|
||||
import BusinessNameInputModal from '../../components/BusinessNameInputModal';
|
||||
import LoginPromptModal from '../../components/LoginPromptModal';
|
||||
import SearchInputForm from '../../components/SearchInputForm';
|
||||
import { SearchType } from '../../components/SearchInputForm';
|
||||
|
||||
// Orb configuration with movement zones to prevent overlap
|
||||
interface OrbConfig {
|
||||
id: string;
|
||||
size: number;
|
||||
initialX: number;
|
||||
initialY: number;
|
||||
color: string;
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minY: number;
|
||||
maxY: number;
|
||||
}
|
||||
|
||||
const orbConfigs: OrbConfig[] = [
|
||||
{ id: 'orb-1', size: 500, initialX: -10, initialY: -10, color: 'radial-gradient(circle, #C490FF 20%, #AE72F9 50%, rgba(94, 235, 195, 0.4) 100%)', minX: -30, maxX: 35, minY: -30, maxY: 40 },
|
||||
{ id: 'orb-2', size: 480, initialX: 70, initialY: -5, color: 'radial-gradient(circle, #5EEBC3 25%, rgba(174, 114, 249, 0.6) 70%, rgba(139, 92, 246, 0.3) 100%)', minX: 50, maxX: 110, minY: -30, maxY: 40 },
|
||||
{ id: 'orb-3', size: 420, initialX: 5, initialY: 35, color: 'radial-gradient(circle, rgba(148, 251, 224, 0.8) 15%, #AE72F9 55%, rgba(94, 235, 195, 0.3) 100%)', minX: -20, maxX: 45, minY: 20, maxY: 65 },
|
||||
{ id: 'orb-4', size: 400, initialX: 60, initialY: 40, color: 'radial-gradient(circle, rgba(220, 200, 255, 0.95) 10%, rgba(148, 251, 224, 0.85) 45%, rgba(174, 114, 249, 0.5) 100%)', minX: 40, maxX: 100, minY: 25, maxY: 70 },
|
||||
{ id: 'orb-5', size: 520, initialX: -8, initialY: 65, color: 'radial-gradient(circle, #B794F6 30%, rgba(148, 251, 224, 0.6) 65%, rgba(174, 114, 249, 0.3) 100%)', minX: -30, maxX: 40, minY: 50, maxY: 110 },
|
||||
{ id: 'orb-6', size: 450, initialX: 65, initialY: 70, color: 'radial-gradient(circle, rgba(180, 255, 235, 0.95) 15%, rgba(200, 160, 255, 0.8) 50%, rgba(94, 235, 195, 0.45) 100%)', minX: 45, maxX: 110, minY: 55, maxY: 110 },
|
||||
];
|
||||
|
||||
interface HeroSectionProps {
|
||||
onAnalyze?: (value: string, type?: SearchType) => void;
|
||||
onAutocomplete?: (data: AutocompleteRequest) => void;
|
||||
onManualInput?: (businessName: string, address: string, category: string) => void;
|
||||
onNext?: () => void;
|
||||
error?: string | null;
|
||||
scrollProgress?: number;
|
||||
}
|
||||
|
||||
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onManualInput, onNext, error: externalError, scrollProgress = 0 }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
|
||||
const [isLoginPromptOpen, setIsLoginPromptOpen] = useState(false);
|
||||
const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const animationRefs = useRef<number[]>([]);
|
||||
const tutorial = useTutorial();
|
||||
|
||||
// 첫 방문 시 랜딩 튜토리얼 시작
|
||||
useEffect(() => {
|
||||
if (!tutorial.hasSeen(TUTORIAL_KEYS.LANDING)) {
|
||||
const timer = setTimeout(() => {
|
||||
tutorial.startTutorial(TUTORIAL_KEYS.LANDING);
|
||||
}, 800);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Orb 랜덤 이동 애니메이션
|
||||
useEffect(() => {
|
||||
const moveOrb = (orb: HTMLDivElement, index: number) => {
|
||||
const config = orbConfigs[index];
|
||||
let currentX = config.initialX;
|
||||
let currentY = config.initialY;
|
||||
let targetX = currentX;
|
||||
let targetY = currentY;
|
||||
let scale = 1;
|
||||
let targetScale = 1;
|
||||
let isFirstMove = true;
|
||||
|
||||
const generateNewTarget = () => {
|
||||
const rangeX = config.maxX - config.minX;
|
||||
const rangeY = config.maxY - config.minY;
|
||||
if (isFirstMove) {
|
||||
const smallRangeX = rangeX * 0.3;
|
||||
const smallRangeY = rangeY * 0.3;
|
||||
targetX = currentX + (Math.random() - 0.5) * smallRangeX;
|
||||
targetY = currentY + (Math.random() - 0.5) * smallRangeY;
|
||||
targetX = Math.max(config.minX, Math.min(config.maxX, targetX));
|
||||
targetY = Math.max(config.minY, Math.min(config.maxY, targetY));
|
||||
isFirstMove = false;
|
||||
} else {
|
||||
targetX = config.minX + Math.random() * rangeX;
|
||||
targetY = config.minY + Math.random() * rangeY;
|
||||
}
|
||||
targetScale = 0.9 + Math.random() * 0.2;
|
||||
};
|
||||
|
||||
const animate = () => {
|
||||
const speed = 0.003;
|
||||
currentX += (targetX - currentX) * speed;
|
||||
currentY += (targetY - currentY) * speed;
|
||||
scale += (targetScale - scale) * speed;
|
||||
orb.style.left = `${currentX}%`;
|
||||
orb.style.top = `${currentY}%`;
|
||||
orb.style.transform = `scale(${scale})`;
|
||||
const distance = Math.sqrt(Math.pow(targetX - currentX, 2) + Math.pow(targetY - currentY, 2));
|
||||
if (distance < 1) generateNewTarget();
|
||||
animationRefs.current[index] = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
generateNewTarget();
|
||||
animate();
|
||||
};
|
||||
|
||||
orbRefs.current.forEach((orb, index) => {
|
||||
if (orb) moveOrb(orb, index);
|
||||
});
|
||||
|
||||
return () => {
|
||||
animationRefs.current.forEach(id => cancelAnimationFrame(id));
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="hero-section">
|
||||
{/* Animated background orbs */}
|
||||
<div
|
||||
className="hero-bg-orbs"
|
||||
style={{ opacity: Math.max(0, 1 - scrollProgress * 3) }}
|
||||
>
|
||||
{orbConfigs.map((config, index) => (
|
||||
<div
|
||||
key={config.id}
|
||||
ref={el => { orbRefs.current[index] = el; }}
|
||||
className="hero-orb-random"
|
||||
style={{
|
||||
width: `${config.size}px`,
|
||||
height: `${config.size}px`,
|
||||
background: config.color,
|
||||
left: `${config.initialX}%`,
|
||||
top: `${config.initialY}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hero-content">
|
||||
<img
|
||||
src="/assets/images/ADO2_with Slogan_white.svg"
|
||||
alt="ADO2"
|
||||
className="hero-logo"
|
||||
/>
|
||||
|
||||
<SearchInputForm
|
||||
onAnalyze={onAnalyze}
|
||||
onAutocomplete={onAutocomplete}
|
||||
onManualInput={onManualInput}
|
||||
onManualButtonClick={() => {
|
||||
if (isLoggedIn()) {
|
||||
setIsManualModalOpen(true);
|
||||
} else {
|
||||
setIsLoginPromptOpen(true);
|
||||
}
|
||||
}}
|
||||
error={externalError || null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer Indicator */}
|
||||
<button onClick={onNext} className="scroll-indicator">
|
||||
<span className="scroll-indicator-text">{t('landing.hero.scrollMore')}</span>
|
||||
<div className="scroll-indicator-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M7 10l5 5 5-5" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{tutorial.isActive && !isManualModalOpen && (
|
||||
<TutorialOverlay
|
||||
hints={tutorial.hints}
|
||||
currentIndex={tutorial.currentHintIndex}
|
||||
onNext={tutorial.nextHint}
|
||||
onPrev={tutorial.prevHint}
|
||||
onSkip={tutorial.skipTutorial}
|
||||
groupProgress={tutorial.groupProgress}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isManualModalOpen && (
|
||||
<BusinessNameInputModal
|
||||
onClose={() => setIsManualModalOpen(false)}
|
||||
onSubmit={(businessName, address, category) => {
|
||||
if (tutorial.isActive) tutorial.nextHint();
|
||||
setIsManualModalOpen(false);
|
||||
onManualInput?.(businessName, address, category);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoginPromptOpen && (
|
||||
<LoginPromptModal onClose={() => setIsLoginPromptOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSection;
|
||||
@ -1,70 +0,0 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface WelcomeSectionProps {
|
||||
onStartClick?: () => void;
|
||||
onNext?: () => void;
|
||||
}
|
||||
|
||||
const WelcomeSection: React.FC<WelcomeSectionProps> = () => {
|
||||
const { t } = useTranslation();
|
||||
const features = [
|
||||
{
|
||||
id: 1,
|
||||
title: t('landing.welcome.feature1Title'),
|
||||
description: t('landing.welcome.feature1Desc'),
|
||||
iconBg: '#9BCACC',
|
||||
icon: '/assets/images/icon-analysis.svg'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: t('landing.welcome.feature2Title'),
|
||||
description: t('landing.welcome.feature2Desc'),
|
||||
iconBg: '#DFC7FD',
|
||||
icon: '/assets/images/icon-content.svg'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: t('landing.welcome.feature3Title'),
|
||||
description: t('landing.welcome.feature3Desc'),
|
||||
iconBg: '#D4FDF3',
|
||||
icon: '/assets/images/icon-deploy.svg'
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="welcome-section">
|
||||
<div className="welcome-content">
|
||||
{/* Star Icon - Top Center */}
|
||||
<div className="welcome-star">
|
||||
<img src="/assets/images/star-icon.svg" alt="star" />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="welcome-header">
|
||||
<h2 className="welcome-title">{t('landing.welcome.title')}</h2>
|
||||
<p className="welcome-subtitle">{t('landing.welcome.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Feature Cards */}
|
||||
<div className="feature-grid">
|
||||
{features.map((feature) => (
|
||||
<div key={feature.id} className="feature-card">
|
||||
<div className="feature-number-badge">
|
||||
<span>{feature.id}</span>
|
||||
</div>
|
||||
<h3 className="feature-card-title">{feature.title}</h3>
|
||||
<div className="feature-icon-box" style={{ backgroundColor: feature.iconBg }}>
|
||||
<img src={feature.icon} alt={feature.title} className="feature-icon-img" />
|
||||
</div>
|
||||
<p className="feature-card-description">{feature.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WelcomeSection;
|
||||
@ -1,109 +0,0 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getKakaoLoginUrl, kakaoCallback, trackCompleteRegistration } from '../../utils/api';
|
||||
|
||||
interface LoginSectionProps {
|
||||
onBack: () => void;
|
||||
onLogin: () => void;
|
||||
}
|
||||
|
||||
const LoginSection: React.FC<LoginSectionProps> = ({ onBack, onLogin }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 카카오 콜백 처리 (URL에서 code 파라미터 확인)
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
|
||||
if (code) {
|
||||
handleKakaoCallback(code);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleKakaoCallback = async (code: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await kakaoCallback(code);
|
||||
|
||||
// 신규 가입이면 Meta CompleteRegistration 전환 이벤트 발화
|
||||
// (서버가 계정당 최초 1회 판정 — App.tsx 경로와 중복 호출돼도 무해)
|
||||
if (response.is_new_user) {
|
||||
await trackCompleteRegistration();
|
||||
}
|
||||
|
||||
// URL에서 code 파라미터 제거
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('code');
|
||||
window.history.replaceState({}, document.title, url.pathname);
|
||||
|
||||
// 로그인 성공
|
||||
onLogin();
|
||||
} catch (err) {
|
||||
console.error('Kakao callback failed:', err);
|
||||
setError(t('login.kakaoLoginFailed'));
|
||||
|
||||
// URL에서 code 파라미터 제거
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('code');
|
||||
window.history.replaceState({}, document.title, url.pathname);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKakaoLogin = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await getKakaoLoginUrl();
|
||||
|
||||
// 카카오 로그인 페이지로 리다이렉트
|
||||
window.location.href = response.auth_url;
|
||||
} catch (err) {
|
||||
console.error('Failed to get Kakao login URL:', err);
|
||||
setError(t('login.loginUrlFailed'));
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
{/* Back Button */}
|
||||
<button onClick={onBack} className="login-back-btn" disabled={isLoading}>
|
||||
<img src="/assets/images/icon-back.svg" alt="Back" />
|
||||
<span>{t('login.back')}</span>
|
||||
</button>
|
||||
|
||||
<div className="login-content">
|
||||
{/* Logo */}
|
||||
<div className="login-logo">
|
||||
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" />
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="login-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Kakao Login Button */}
|
||||
<button
|
||||
onClick={handleKakaoLogin}
|
||||
className="btn-kakao"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? t('login.loggingIn') : t('login.kakaoStart')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginSection;
|
||||
@ -5,6 +5,10 @@
|
||||
.social-posting-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
/* 마운트 위치의 텍스트 정렬을 상속하지 않는다.
|
||||
position:fixed 라도 DOM 상속은 끊기지 않아, 중앙정렬된 부모
|
||||
(.ssul-scope.ssul-result 등) 안에서 열면 폼 라벨까지 가운데로 쏠린다. */
|
||||
text-align: left;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -935,7 +939,7 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #FF0000;
|
||||
background: #f87171;
|
||||
color: #FFFFFF;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
@ -1024,397 +1028,8 @@
|
||||
.calendar-panel-scroll {
|
||||
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: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tutorial-overlay-blocker {
|
||||
position: fixed;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
@keyframes tutorial-pulse {
|
||||
0%, 100% {
|
||||
border-color: rgba(166, 255, 234, 0.9);
|
||||
box-shadow: 0 0 0 2px rgba(166, 255, 234, 0.3),
|
||||
0 0 16px 4px rgba(166, 255, 234, 0.35),
|
||||
0 0 40px 8px rgba(166, 255, 234, 0.15);
|
||||
}
|
||||
50% {
|
||||
border-color: rgba(166, 255, 234, 0.5);
|
||||
box-shadow: 0 0 0 5px rgba(166, 255, 234, 0.12),
|
||||
0 0 32px 10px rgba(166, 255, 234, 0.5),
|
||||
0 0 64px 20px rgba(166, 255, 234, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.tutorial-spotlight-ring {
|
||||
position: fixed;
|
||||
border: 4px solid rgba(166, 255, 234, 0.85);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 0 0 1px rgba(166, 255, 234, 0.2), 0 0 12px 2px rgba(166, 255, 234, 0.15);
|
||||
pointer-events: none;
|
||||
z-index: 10001;
|
||||
animation: tutorial-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.tutorial-tooltip {
|
||||
position: fixed;
|
||||
pointer-events: all;
|
||||
z-index: 10002;
|
||||
display: flex;
|
||||
width: 370px;
|
||||
padding: 20px;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--Color-teal-600, #046266);
|
||||
background: var(--Color-teal-800, #002224);
|
||||
}
|
||||
|
||||
.tutorial-tooltip-title {
|
||||
align-self: stretch;
|
||||
color: rgba(255, 255, 255, 0.70);
|
||||
/* 14 */
|
||||
font-family: Pretendard;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 128.571% */
|
||||
}
|
||||
|
||||
.tutorial-tooltip-desc {
|
||||
display: flex;
|
||||
padding-bottom: 16px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
align-self: stretch;
|
||||
line-height: 1.5;
|
||||
white-space: pre-line;
|
||||
align-self: stretch;
|
||||
color: var(--Color-white, #FFF);
|
||||
font-family: Pretendard;
|
||||
font-size: 18px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 24px; /* 133.333% */
|
||||
letter-spacing: -0.108px;
|
||||
}
|
||||
|
||||
.tutorial-tooltip-note {
|
||||
align-self: stretch;
|
||||
color: rgba(255, 255, 255, 0.60);
|
||||
font-family: Pretendard;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 100%;
|
||||
letter-spacing: -0.072px;
|
||||
margin: -8px 0 14px;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.tutorial-tooltip-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.tutorial-tooltip-footer-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tutorial-tooltip-counter {
|
||||
color: rgba(255, 255, 255, 0.60);
|
||||
font-family: Pretendard;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 100%; /* 12px */
|
||||
letter-spacing: -0.072px;
|
||||
}
|
||||
|
||||
.tutorial-tooltip-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tutorial-btn-skip {
|
||||
color: rgba(255, 255, 255, 0.60);
|
||||
font-family: Pretendard;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 100%; /* 12px */
|
||||
letter-spacing: -0.072px;
|
||||
}
|
||||
|
||||
.tutorial-btn-skip:hover {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.tutorial-btn-prev {
|
||||
display: flex;
|
||||
padding: 8px 12px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-radius: 4px;
|
||||
background: var(--Color-teal-200, #9BCACC);
|
||||
color: var(--Color-teal-800, #002224);
|
||||
font-family: Pretendard;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 100%; /* 13px */
|
||||
letter-spacing: -0.078px;
|
||||
}
|
||||
|
||||
.tutorial-btn-prev:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.tutorial-btn-next {
|
||||
display: flex;
|
||||
padding: 8px 12px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-radius: 4px;
|
||||
background: var(--Color-teal-200, #9BCACC);
|
||||
color: var(--Color-teal-800, #002224);
|
||||
font-family: Pretendard;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 100%; /* 13px */
|
||||
letter-spacing: -0.078px;
|
||||
}
|
||||
|
||||
.tutorial-btn-next:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
/* 말풍선 variant — 색상은 기본 tooltip과 동일, 꼬리만 추가 */
|
||||
.tutorial-tooltip-bubble {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* 꼬리 (화살표) */
|
||||
.tutorial-tooltip-bubble::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border: 10px solid transparent;
|
||||
}
|
||||
|
||||
.tutorial-tooltip-bubble--bottom::before {
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-bottom-color: #1a2630;
|
||||
}
|
||||
|
||||
.tutorial-tooltip-bubble--top::before {
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-top-color: #1a2630;
|
||||
}
|
||||
|
||||
.tutorial-tooltip-bubble--right::before {
|
||||
right: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-right-color: #1a2630;
|
||||
}
|
||||
|
||||
.tutorial-tooltip-bubble--left::before {
|
||||
left: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-left-color: #1a2630;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 튜토리얼 토글 버튼 */
|
||||
.tutorial-toggle-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.5);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
backdrop-filter: blur(6px);
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.tutorial-toggle-fab:hover {
|
||||
background: rgba(166, 255, 234, 0.12);
|
||||
border-color: rgba(166, 255, 234, 0.5);
|
||||
color: #a6ffea;
|
||||
}
|
||||
|
||||
.tutorial-toggle-fab.active {
|
||||
color: #a6ffea;
|
||||
border-color: rgba(166, 255, 234, 0.4);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.tutorial-toggle-fab {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tutorial-toggle-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.tutorial-toggle-badge.on {
|
||||
background: #a6ffea;
|
||||
color: #002224;
|
||||
}
|
||||
|
||||
.tutorial-toggle-badge.off {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
/* 패널 끝까지 스크롤해도 뒤의 캘린더/페이지로 스크롤이 전파되지 않게 한다 */
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* BusinessNameInputModal */
|
||||
|
||||
Loading…
Reference in New Issue
Block a user