Merge branch 'main' into feature-ssulbox
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
4fe065378f
BIN
public/assets/images/ado2_image.png
Normal file
BIN
public/assets/images/ado2_image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
416
src/App.tsx
416
src/App.tsx
@ -1,16 +1,28 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useRef, useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { 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 GenerationFlow from './pages/Dashboard/GenerationFlow';
|
||||||
import SocialConnectSuccess from './pages/Social/SocialConnectSuccess';
|
import SocialConnectSuccess from './pages/Social/SocialConnectSuccess';
|
||||||
import SocialConnectError from './pages/Social/SocialConnectError';
|
import SocialConnectError from './pages/Social/SocialConnectError';
|
||||||
import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback';
|
import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback';
|
||||||
|
import ADO2ContentsPage from './pages/Dashboard/ADO2ContentsPage';
|
||||||
import VideoDetailPage from './components/VideoDetailPage';
|
import VideoDetailPage from './components/VideoDetailPage';
|
||||||
import SsulDetailPage from './pages/Ssulbox/SsulDetailPage';
|
import SsulDetailPage from './pages/Ssulbox/SsulDetailPage';
|
||||||
import { kakaoCallback, saveTokens, storeUtmFromUrl, trackViewContent, trackCompleteRegistration } from './utils/api';
|
import { crawlUrl, autocomplete, marketingAnalysis, kakaoCallback, isLoggedIn, saveTokens, getVideosList, AutocompleteRequest, storeUtmFromUrl, trackViewContent, trackCompleteRegistration } from './utils/api';
|
||||||
import { clearSessionStorage } from './utils/storageKeys';
|
import { saveSearchHistory } from './components/SearchHistory/useSearchHistory';
|
||||||
import { NAV } from './components/navItems';
|
import { CrawlingResponse } from './types/api';
|
||||||
|
|
||||||
|
type ViewMode = 'landing' | 'loading' | 'analysis' | 'login' | 'generation_flow';
|
||||||
|
|
||||||
|
const VIEW_MODE_KEY = 'castad_view_mode';
|
||||||
|
const ANALYSIS_DATA_KEY = 'castad_analysis_data';
|
||||||
const SESSION_KEY = 'castad_session_active';
|
const SESSION_KEY = 'castad_session_active';
|
||||||
|
|
||||||
// 새 탭/새 창에서 접근 시 localStorage 초기화 (sessionStorage로 현재 세션 확인)
|
// 새 탭/새 창에서 접근 시 localStorage 초기화 (sessionStorage로 현재 세션 확인)
|
||||||
@ -19,7 +31,15 @@ const initializeOnNewSession = () => {
|
|||||||
|
|
||||||
if (!isExistingSession) {
|
if (!isExistingSession) {
|
||||||
// 새 세션이면 localStorage 정리하고 세션 표시
|
// 새 세션이면 localStorage 정리하고 세션 표시
|
||||||
clearSessionStorage();
|
localStorage.removeItem(VIEW_MODE_KEY);
|
||||||
|
localStorage.removeItem(ANALYSIS_DATA_KEY);
|
||||||
|
localStorage.removeItem('castad_wizard_step');
|
||||||
|
localStorage.removeItem('castad_active_item');
|
||||||
|
localStorage.removeItem('castad_song_task_id');
|
||||||
|
localStorage.removeItem('castad_image_task_id');
|
||||||
|
localStorage.removeItem('castad_song_generation');
|
||||||
|
localStorage.removeItem('castad_video_generation');
|
||||||
|
localStorage.removeItem('castad_video_ratio');
|
||||||
sessionStorage.setItem(SESSION_KEY, 'true');
|
sessionStorage.setItem(SESSION_KEY, 'true');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -28,9 +48,79 @@ const initializeOnNewSession = () => {
|
|||||||
initializeOnNewSession();
|
initializeOnNewSession();
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const [initialTab, setInitialTab] = useState<string>(NAV.HOME);
|
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 [isProcessingCallback, setIsProcessingCallback] = useState(false);
|
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 파라미터 확인)
|
// 카카오 로그인 콜백 처리 (URL에서 토큰 또는 code 파라미터 확인)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -57,8 +147,8 @@ const App: React.FC = () => {
|
|||||||
setIsProcessingCallback(true);
|
setIsProcessingCallback(true);
|
||||||
handleKakaoCallback(code);
|
handleKakaoCallback(code);
|
||||||
}
|
}
|
||||||
// 콜백이 아닌 일반 진입 — 광고 유입 추적
|
// 콜백이 아닌 일반 진입이고 랜딩이 첫 화면이면 ViewContent 발화
|
||||||
else {
|
else if (viewMode === 'landing') {
|
||||||
trackViewContent();
|
trackViewContent();
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@ -82,15 +172,21 @@ const App: React.FC = () => {
|
|||||||
await trackCompleteRegistration();
|
await trackCompleteRegistration();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 로그인 전 특정 화면(예: /video/{id})에서 유도된 로그인이면 그 경로로 복귀
|
const savedData = localStorage.getItem(ANALYSIS_DATA_KEY);
|
||||||
|
if (savedData) {
|
||||||
|
// 분석 데이터가 있으면 에셋 관리(step 1)부터 시작
|
||||||
|
// 이전에 저장된 wizard step이 URL 입력(-2) 등으로 남아있을 수 있으므로 초기화
|
||||||
|
localStorage.removeItem('castad_wizard_step');
|
||||||
|
localStorage.removeItem('castad_active_item');
|
||||||
|
}
|
||||||
const redirectPath = sessionStorage.getItem('castad_login_redirect');
|
const redirectPath = sessionStorage.getItem('castad_login_redirect');
|
||||||
sessionStorage.removeItem('castad_login_redirect');
|
sessionStorage.removeItem('castad_login_redirect');
|
||||||
if (redirectPath && redirectPath !== '/') {
|
if (redirectPath && redirectPath !== '/') {
|
||||||
window.location.href = redirectPath;
|
window.location.href = redirectPath;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 그 외(사이드바 게이트 등)는 GenerationFlow 가 castad_login_next_item 으로 자체 복귀 처리
|
setInitialTab('새 프로젝트 만들기');
|
||||||
setInitialTab(NAV.HOME);
|
setViewMode('generation_flow');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Token callback failed:', err);
|
console.error('Token callback failed:', err);
|
||||||
alert(t('app.loginFailed'));
|
alert(t('app.loginFailed'));
|
||||||
@ -124,6 +220,231 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// viewMode 변경 시 localStorage에 저장
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(VIEW_MODE_KEY, viewMode);
|
||||||
|
}, [viewMode]);
|
||||||
|
|
||||||
|
// 스크롤 이벤트 핸들러 - 첫 번째 섹션에서 두 번째 섹션으로 넘어갈 때 0~1 값 계산
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container || viewMode !== 'landing') return;
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
const scrollTop = container.scrollTop;
|
||||||
|
const sectionHeight = container.clientHeight;
|
||||||
|
// 첫 번째 섹션 스크롤 진행률 (0 ~ 1)
|
||||||
|
const progress = Math.min(1, Math.max(0, scrollTop / sectionHeight));
|
||||||
|
setScrollProgress(progress);
|
||||||
|
};
|
||||||
|
|
||||||
|
container.addEventListener('scroll', handleScroll);
|
||||||
|
return () => container.removeEventListener('scroll', handleScroll);
|
||||||
|
}, [viewMode]);
|
||||||
|
|
||||||
|
const scrollToSection = (index: number) => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
const h = containerRef.current.clientHeight;
|
||||||
|
containerRef.current.scrollTo({
|
||||||
|
top: h * index,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 크롤링 응답 유효성 검사
|
||||||
|
const validateCrawlingResponse = (data: CrawlingResponse): boolean => {
|
||||||
|
// 필수 필드 존재 여부 확인
|
||||||
|
if (!data) return false;
|
||||||
|
if (!data.processed_info) return false;
|
||||||
|
if (!data.marketing_analysis) return false;
|
||||||
|
|
||||||
|
// marketing_analysis 내부 필드 기본값 보장
|
||||||
|
if (!data.marketing_analysis.brand_identity) {
|
||||||
|
data.marketing_analysis.brand_identity = {
|
||||||
|
location_feature_analysis: '',
|
||||||
|
concept_scalability: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!data.marketing_analysis.market_positioning) {
|
||||||
|
data.marketing_analysis.market_positioning = {
|
||||||
|
category_definition: '',
|
||||||
|
core_value: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!data.marketing_analysis.target_persona) {
|
||||||
|
data.marketing_analysis.target_persona = [];
|
||||||
|
}
|
||||||
|
if (!data.marketing_analysis.selling_points) {
|
||||||
|
data.marketing_analysis.selling_points = [];
|
||||||
|
}
|
||||||
|
if (!data.marketing_analysis.target_keywords) {
|
||||||
|
data.marketing_analysis.target_keywords = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// processed_info 내부 필드 기본값 보장
|
||||||
|
if (!data.processed_info.customer_name) {
|
||||||
|
data.processed_info.customer_name = '알 수 없음';
|
||||||
|
}
|
||||||
|
if (!data.processed_info.region) {
|
||||||
|
data.processed_info.region = '';
|
||||||
|
}
|
||||||
|
if (!data.processed_info.detail_region_info) {
|
||||||
|
data.processed_info.detail_region_info = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// image_list 기본값 보장
|
||||||
|
if (!data.image_list) {
|
||||||
|
data.image_list = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartAnalysis = async (url: string) => {
|
||||||
|
if (!url.trim()) return;
|
||||||
|
|
||||||
|
setAfterLoadTarget('analysis');
|
||||||
|
setViewMode('loading');
|
||||||
|
setIsAnalysisComplete(false);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await crawlUrl(url);
|
||||||
|
|
||||||
|
// 응답 유효성 검사
|
||||||
|
if (!validateCrawlingResponse(data)) {
|
||||||
|
throw new Error(t('app.invalidUrl'));
|
||||||
|
}
|
||||||
|
|
||||||
|
setAnalysisData(data);
|
||||||
|
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
|
||||||
|
saveSearchHistory({ type: 'url', value: url });
|
||||||
|
setIsAnalysisComplete(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Crawling failed:', err);
|
||||||
|
setError(t('app.analysisError'));
|
||||||
|
setViewMode('landing');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 업체명 자동완성으로 분석 시작
|
||||||
|
const handleAutocomplete = async (request: AutocompleteRequest) => {
|
||||||
|
setAfterLoadTarget('analysis');
|
||||||
|
setViewMode('loading');
|
||||||
|
setIsAnalysisComplete(false);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await autocomplete(request);
|
||||||
|
|
||||||
|
// 응답 유효성 검사
|
||||||
|
if (!validateCrawlingResponse(data)) {
|
||||||
|
throw new Error(t('app.autocompleteError'));
|
||||||
|
}
|
||||||
|
|
||||||
|
setAnalysisData(data);
|
||||||
|
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
|
||||||
|
saveSearchHistory({ type: 'name', value: request.title.replace(/<[^>]*>/g, ''), address: request.address, roadAddress: request.roadAddress });
|
||||||
|
setIsAnalysisComplete(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Autocomplete failed:', err);
|
||||||
|
setError(t('app.autocompleteError'));
|
||||||
|
setViewMode('landing');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
|
||||||
|
const handleManualInput = async (businessName: string, address: string, category: string) => {
|
||||||
|
setAfterLoadTarget('generation_flow');
|
||||||
|
setViewMode('loading');
|
||||||
|
setIsAnalysisComplete(false);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await marketingAnalysis(businessName, address, category);
|
||||||
|
|
||||||
|
if (!validateCrawlingResponse(data)) {
|
||||||
|
throw new Error(t('app.autocompleteError'));
|
||||||
|
}
|
||||||
|
|
||||||
|
setAnalysisData(data);
|
||||||
|
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
|
||||||
|
saveSearchHistory({ type: 'name', value: businessName, address, roadAddress: address });
|
||||||
|
setIsAnalysisComplete(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Marketing analysis failed:', err);
|
||||||
|
setError(t('app.autocompleteError'));
|
||||||
|
setViewMode('landing');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 테스트 데이터로 브랜드 분석 페이지 이동
|
||||||
|
const handleTestData = (data: CrawlingResponse) => {
|
||||||
|
const tagged = { ...data, _isTestData: true };
|
||||||
|
setAnalysisData(tagged);
|
||||||
|
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
|
||||||
|
setViewMode('analysis');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 언어 변경 시 테스트 데이터 다시 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem(ANALYSIS_DATA_KEY);
|
||||||
|
if (!saved) return;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (!parsed._isTestData) return;
|
||||||
|
const jsonFile = i18n.language === 'en' ? '/example_analysis_en.json' : '/example_analysis.json';
|
||||||
|
fetch(jsonFile)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then((data: CrawlingResponse) => {
|
||||||
|
const tagged = { ...data, _isTestData: true };
|
||||||
|
setAnalysisData(tagged);
|
||||||
|
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
|
||||||
|
})
|
||||||
|
.catch(err => console.error('Failed to reload test data:', err));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [i18n.language]);
|
||||||
|
|
||||||
|
const handleToLogin = async () => {
|
||||||
|
// 이미 로그인된 상태면 바로 generation_flow로 이동
|
||||||
|
if (isLoggedIn()) {
|
||||||
|
// 분석 데이터가 있으면 이전 wizard step 초기화 (에셋 관리부터 시작하도록)
|
||||||
|
const savedData = localStorage.getItem(ANALYSIS_DATA_KEY);
|
||||||
|
if (savedData) {
|
||||||
|
localStorage.removeItem('castad_wizard_step');
|
||||||
|
localStorage.removeItem('castad_active_item');
|
||||||
|
}
|
||||||
|
setInitialTab('새 프로젝트 만들기');
|
||||||
|
setViewMode('generation_flow');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로그인 안 된 상태면 카카오 로그인 페이지로 리다이렉션
|
||||||
|
try {
|
||||||
|
const { getKakaoLoginUrl } = await import('./utils/api');
|
||||||
|
const response = await getKakaoLoginUrl();
|
||||||
|
window.location.href = response.auth_url;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to get Kakao login URL:', err);
|
||||||
|
alert(t('app.loginUrlFailed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoginSuccess = () => {
|
||||||
|
setInitialTab('새 프로젝트 만들기');
|
||||||
|
setViewMode('generation_flow');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoBack = () => {
|
||||||
|
// localStorage 정리
|
||||||
|
localStorage.removeItem(VIEW_MODE_KEY);
|
||||||
|
localStorage.removeItem(ANALYSIS_DATA_KEY);
|
||||||
|
localStorage.removeItem('castad_wizard_step');
|
||||||
|
localStorage.removeItem('castad_active_item');
|
||||||
|
setViewMode('landing');
|
||||||
|
};
|
||||||
|
|
||||||
// Social OAuth 콜백 페이지 처리
|
// Social OAuth 콜백 페이지 처리
|
||||||
const pathname = window.location.pathname;
|
const pathname = window.location.pathname;
|
||||||
|
|
||||||
@ -146,14 +467,12 @@ const App: React.FC = () => {
|
|||||||
return <VideoDetailPage videoId={videoDetailMatch[1]} />;
|
return <VideoDetailPage videoId={videoDetailMatch[1]} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 썰박스 공유 페이지 (/ssul/{id}) — 위와 동일한 패턴, 비로그인 열람 가능.
|
|
||||||
// /video/{id} 와 경로를 나눈 이유: video.id 와 ssul_content.id 가 독립 시퀀스라
|
|
||||||
// 값이 겹치므로, 한 경로로 합치면 다른 종류의 콘텐츠가 열린다.
|
|
||||||
const ssulDetailMatch = pathname.match(/^\/ssul\/(\d+)$/);
|
const ssulDetailMatch = pathname.match(/^\/ssul\/(\d+)$/);
|
||||||
if (ssulDetailMatch) {
|
if (ssulDetailMatch) {
|
||||||
return <SsulDetailPage contentId={ssulDetailMatch[1]} />;
|
return <SsulDetailPage contentId={ssulDetailMatch[1]} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 카카오 콜백 처리 중 로딩 화면 표시
|
// 카카오 콜백 처리 중 로딩 화면 표시
|
||||||
if (isProcessingCallback) {
|
if (isProcessingCallback) {
|
||||||
return (
|
return (
|
||||||
@ -165,8 +484,71 @@ const App: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 그 외 전 경로는 ADO2 콘텐츠 갤러리를 첫 화면으로 하는 GenerationFlow 로 수렴
|
if (viewMode === 'loading') {
|
||||||
return <GenerationFlow initialActiveItem={initialTab} />;
|
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>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { toggleVideoLike, isLoggedIn } from '../utils/api';
|
import { API_URL, toggleVideoLike, isLoggedIn } from '../utils/api';
|
||||||
|
import { buildContentShareUrl, tryNativeShare } from '../utils/nativeShare';
|
||||||
import { ContentType } from '../types/api';
|
import { ContentType } from '../types/api';
|
||||||
import LoginPromptModal from './LoginPromptModal';
|
import LoginPromptModal from './LoginPromptModal';
|
||||||
|
|
||||||
@ -33,102 +33,29 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
|
|||||||
const [likeCount, setLikeCount] = useState(initialLikeCount);
|
const [likeCount, setLikeCount] = useState(initialLikeCount);
|
||||||
const [isLiked, setIsLiked] = useState(initialIsLiked);
|
const [isLiked, setIsLiked] = useState(initialIsLiked);
|
||||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const [shareMenuOpen, setShareMenuOpen] = useState(false);
|
|
||||||
const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null);
|
|
||||||
const [anchorRect, setAnchorRect] = useState<{ top: number; bottom: number; left: number } | null>(null);
|
|
||||||
|
|
||||||
const shareBtnRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const shareMenuRef = useRef<HTMLDivElement>(null);
|
|
||||||
const likeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const likeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// 종류별 공개 페이지가 다르다 — 합치면 id 가 겹치는 다른 콘텐츠가 열린다
|
const shareUrl = buildContentShareUrl(API_URL, videoId, contentType);
|
||||||
const shareUrl = `${window.location.origin}/${contentType === 'ssul' ? 'ssul' : 'video'}/${videoId}`;
|
const shareTitle = storeName || t('videoDetail.kakaoDefaultTitle');
|
||||||
|
const shareDescription = t('videoDetail.kakaoDescription', { region: region ?? '' });
|
||||||
|
|
||||||
useEffect(() => {
|
const handleShareBtnClick = async (e: React.MouseEvent) => {
|
||||||
if (!shareMenuOpen) return;
|
e.stopPropagation();
|
||||||
|
|
||||||
const closeMenu = () => setShareMenuOpen(false);
|
const handled = await tryNativeShare({
|
||||||
|
title: shareTitle,
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
text: shareDescription,
|
||||||
if (
|
url: shareUrl,
|
||||||
shareMenuRef.current && !shareMenuRef.current.contains(e.target as Node) &&
|
});
|
||||||
shareBtnRef.current && !shareBtnRef.current.contains(e.target as Node)
|
if (handled) {
|
||||||
) {
|
return;
|
||||||
closeMenu();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
window.addEventListener('scroll', closeMenu, true);
|
|
||||||
window.addEventListener('resize', closeMenu);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
window.removeEventListener('scroll', closeMenu, true);
|
|
||||||
window.removeEventListener('resize', closeMenu);
|
|
||||||
};
|
|
||||||
}, [shareMenuOpen]);
|
|
||||||
|
|
||||||
// 메뉴가 뷰포트 밖으로 넘어가지 않도록 실제 렌더된 크기 기준으로 위치 보정
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!shareMenuOpen || !anchorRect || !shareMenuRef.current) return;
|
|
||||||
const margin = 8;
|
|
||||||
const menuRect = shareMenuRef.current.getBoundingClientRect();
|
|
||||||
|
|
||||||
let left = anchorRect.left;
|
|
||||||
if (left + menuRect.width > window.innerWidth - margin) {
|
|
||||||
left = window.innerWidth - menuRect.width - margin;
|
|
||||||
}
|
}
|
||||||
left = Math.max(margin, left);
|
|
||||||
|
|
||||||
let top = anchorRect.bottom + 6;
|
|
||||||
if (top + menuRect.height > window.innerHeight - margin) {
|
|
||||||
top = anchorRect.top - menuRect.height - 6;
|
|
||||||
}
|
|
||||||
top = Math.max(margin, top);
|
|
||||||
|
|
||||||
setMenuPos((prev) => (prev && prev.top === top && prev.left === left ? prev : { top, left }));
|
|
||||||
}, [shareMenuOpen, anchorRect]);
|
|
||||||
|
|
||||||
const handleCopyLink = async () => {
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(shareUrl);
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
} catch {
|
} catch {
|
||||||
// clipboard API 미지원 환경에서는 무시
|
// clipboard API 미지원 환경에서는 무시
|
||||||
}
|
}
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKakaoShare = () => {
|
|
||||||
const kakao = window.Kakao;
|
|
||||||
if (kakao?.Share) {
|
|
||||||
kakao.Share.sendDefault({
|
|
||||||
objectType: 'feed',
|
|
||||||
content: {
|
|
||||||
title: storeName || t('videoDetail.kakaoDefaultTitle'),
|
|
||||||
description: t('videoDetail.kakaoDescription', { region: region ?? '' }),
|
|
||||||
imageUrl: 'https://ado2.o2osolution.ai/favicon_48.svg',
|
|
||||||
link: { mobileWebUrl: shareUrl, webUrl: shareUrl },
|
|
||||||
},
|
|
||||||
buttons: [{ title: t('videoDetail.kakaoButtonTitle'), link: { mobileWebUrl: shareUrl, webUrl: shareUrl } }],
|
|
||||||
});
|
|
||||||
} else if (navigator.share) {
|
|
||||||
navigator.share({ url: shareUrl }).catch(() => {});
|
|
||||||
} else {
|
|
||||||
handleCopyLink();
|
|
||||||
}
|
|
||||||
setShareMenuOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFacebookShare = () => {
|
|
||||||
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600');
|
|
||||||
setShareMenuOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTwitterShare = () => {
|
|
||||||
window.open(`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600');
|
|
||||||
setShareMenuOpen(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLikeClick = (e: React.MouseEvent) => {
|
const handleLikeClick = (e: React.MouseEvent) => {
|
||||||
@ -159,17 +86,6 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
|
|||||||
}, 500);
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleShareBtnClick = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const rect = shareBtnRef.current?.getBoundingClientRect();
|
|
||||||
if (rect) {
|
|
||||||
// 초기 추정 위치(측정 전 첫 렌더용) — useLayoutEffect가 실제 크기 기준으로 다시 보정한다
|
|
||||||
setAnchorRect({ top: rect.top, bottom: rect.bottom, left: rect.left });
|
|
||||||
setMenuPos({ top: rect.bottom + 6, left: rect.left });
|
|
||||||
}
|
|
||||||
setShareMenuOpen((v) => !v);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-card-social" onClick={(e) => e.stopPropagation()}>
|
<div className="content-card-social" onClick={(e) => e.stopPropagation()}>
|
||||||
<button
|
<button
|
||||||
@ -190,7 +106,6 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
ref={shareBtnRef}
|
|
||||||
className="content-card-share-btn"
|
className="content-card-share-btn"
|
||||||
onClick={handleShareBtnClick}
|
onClick={handleShareBtnClick}
|
||||||
title={t('videoDetail.share')}
|
title={t('videoDetail.share')}
|
||||||
@ -201,42 +116,6 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{shareMenuOpen && menuPos && createPortal(
|
|
||||||
<div
|
|
||||||
ref={shareMenuRef}
|
|
||||||
className="video-detail-share-menu"
|
|
||||||
style={{ position: 'fixed', top: menuPos.top, left: menuPos.left }}
|
|
||||||
>
|
|
||||||
<button className="video-detail-share-item" onClick={handleKakaoShare}>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect width="20" height="20" rx="4" fill="#FEE500" />
|
|
||||||
<path fillRule="evenodd" clipRule="evenodd" d="M10 3.5C6.134 3.5 3 6.01 3 9.1c0 1.98 1.2 3.72 3.01 4.76l-.74 2.75a.19.19 0 0 0 .28.21l3.37-2.23c.34.04.69.06 1.06.06 3.866 0 7-2.51 7-5.6S13.866 3.5 10 3.5z" fill="#3C1E1E" />
|
|
||||||
</svg>
|
|
||||||
{t('videoDetail.shareKakao')}
|
|
||||||
</button>
|
|
||||||
<button className="video-detail-share-item" onClick={handleFacebookShare}>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="#1877F2">
|
|
||||||
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.41c0-3.025 1.792-4.697 4.533-4.697 1.312 0 2.686.235 2.686.235v2.97h-1.513c-1.491 0-1.956.93-1.956 1.887v2.268h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z" />
|
|
||||||
</svg>
|
|
||||||
{t('videoDetail.shareFacebook')}
|
|
||||||
</button>
|
|
||||||
<button className="video-detail-share-item" onClick={handleTwitterShare}>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
|
||||||
</svg>
|
|
||||||
{t('videoDetail.shareTwitter')}
|
|
||||||
</button>
|
|
||||||
<button className="video-detail-share-item" onClick={() => { handleCopyLink(); setShareMenuOpen(false); }}>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<rect x="9" y="9" width="13" height="13" rx="2" />
|
|
||||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
||||||
</svg>
|
|
||||||
{copied ? t('videoDetail.copied') : t('videoDetail.copyUrl')}
|
|
||||||
</button>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showLoginModal && (
|
{showLoginModal && (
|
||||||
<LoginPromptModal onClose={() => setShowLoginModal(false)} />
|
<LoginPromptModal onClose={() => setShowLoginModal(false)} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
64
src/components/Header.tsx
Executable file
64
src/components/Header.tsx
Executable file
@ -0,0 +1,64 @@
|
|||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { getKakaoLoginUrl, isLoggedIn } from '../utils/api';
|
||||||
|
import LanguageSwitch from './LanguageSwitch';
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
onStartClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Header: React.FC<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;
|
||||||
@ -5,6 +5,9 @@ import { getSocialAccounts, uploadToSocial, waitForUploadComplete, TokenExpiredE
|
|||||||
import { SocialAccount, VideoListItem, SocialUploadStatusResponse } from '../types/api';
|
import { SocialAccount, VideoListItem, SocialUploadStatusResponse } from '../types/api';
|
||||||
import UploadProgressModal, { UploadStatus } from './UploadProgressModal';
|
import UploadProgressModal, { UploadStatus } from './UploadProgressModal';
|
||||||
import { useOverlayClose } from '../hooks/useOverlayClose';
|
import { useOverlayClose } from '../hooks/useOverlayClose';
|
||||||
|
import { useTutorial } from './Tutorial/useTutorial';
|
||||||
|
import { TUTORIAL_KEYS } from './Tutorial/tutorialSteps';
|
||||||
|
import TutorialOverlay from './Tutorial/TutorialOverlay';
|
||||||
|
|
||||||
interface SocialPostingModalProps {
|
interface SocialPostingModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -119,6 +122,8 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||||||
onGoToCalendar,
|
onGoToCalendar,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const tutorial = useTutorial();
|
||||||
|
const hasBeenOpenedRef = useRef(false);
|
||||||
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
|
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
|
||||||
const [selectedChannel, setSelectedChannel] = useState<string>('');
|
const [selectedChannel, setSelectedChannel] = useState<string>('');
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
@ -180,16 +185,50 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||||||
return () => { document.body.style.overflow = ''; };
|
return () => { document.body.style.overflow = ''; };
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
// 소셜 계정 로드
|
// 모달 오픈/닫힘 시 튜토리얼 트리거
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (isOpen) {
|
||||||
|
hasBeenOpenedRef.current = true;
|
||||||
|
if (!tutorial.hasSeen(TUTORIAL_KEYS.UPLOAD_MODAL)) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
tutorial.startTutorial(TUTORIAL_KEYS.UPLOAD_MODAL);
|
||||||
|
}, 400);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
} else if (hasBeenOpenedRef.current && !tutorial.hasSeen(TUTORIAL_KEYS.FEEDBACK)) {
|
||||||
|
hasBeenOpenedRef.current = false;
|
||||||
|
tutorial.startTutorial(TUTORIAL_KEYS.FEEDBACK);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
const now = Date.now();
|
// SEO 생성 완료 시 UPLOAD_FORM 튜토리얼 트리거
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoadingAutoDescription && isOpen && !tutorial.hasSeen(TUTORIAL_KEYS.UPLOAD_FORM)) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
tutorial.startTutorial(TUTORIAL_KEYS.UPLOAD_FORM);
|
||||||
|
}, 400);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isLoadingAutoDescription]);
|
||||||
|
|
||||||
|
// 소셜 계정 로드 + SEO 자동 채움
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
loadedForTaskIdRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
loadSocialAccounts();
|
loadSocialAccounts();
|
||||||
|
|
||||||
// SEO 자동 채움 키. ADO2 는 task_id, 썰박스는 task_id 가 없어(빈 문자열)
|
if (video?.title) {
|
||||||
// (type, video_id) 로 키를 만든다 — 없으면 썰박스는 자동 채움이 통째로 스킵됐다.
|
setTitle(video.title);
|
||||||
|
setDescription(video.description || '');
|
||||||
|
setTags((video.hashtags || []).join(','));
|
||||||
|
setIsLoadingAutoDescription(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
const seoKey =
|
const seoKey =
|
||||||
video?.type === 'ssul'
|
video?.type === 'ssul'
|
||||||
? `ssul:${video.video_id}`
|
? `ssul:${video.video_id}`
|
||||||
@ -208,7 +247,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||||||
setTags(cached.tags);
|
setTags(cached.tags);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isOpen, video?.task_id, video?.type, video?.video_id]);
|
}, [isOpen, video?.task_id, video?.type, video?.video_id, video?.title, video?.description, video?.hashtags]);
|
||||||
|
|
||||||
const loadSocialAccounts = async () => {
|
const loadSocialAccounts = async () => {
|
||||||
setIsLoadingAccounts(true);
|
setIsLoadingAccounts(true);
|
||||||
@ -251,11 +290,8 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||||||
content_type: video.type,
|
content_type: video.type,
|
||||||
task_id: isSsul ? String(video.video_id) : video.task_id,
|
task_id: isSsul ? String(video.video_id) : video.task_id,
|
||||||
};
|
};
|
||||||
// Call autoSEO API
|
|
||||||
console.log('[Upload] Request payload:', requestPayload);
|
|
||||||
const autoSeoResponse = await getAutoSeoYoutube(requestPayload);
|
const autoSeoResponse = await getAutoSeoYoutube(requestPayload);
|
||||||
|
|
||||||
// 각 필드가 있을 때만 덮어씌움 (기존 값 보호)
|
|
||||||
if (autoSeoResponse.title) setTitle(autoSeoResponse.title);
|
if (autoSeoResponse.title) setTitle(autoSeoResponse.title);
|
||||||
if (autoSeoResponse.description) setDescription(autoSeoResponse.description);
|
if (autoSeoResponse.description) setDescription(autoSeoResponse.description);
|
||||||
if (autoSeoResponse.keywords) setTags(autoSeoResponse.keywords.join(','));
|
if (autoSeoResponse.keywords) setTags(autoSeoResponse.keywords.join(','));
|
||||||
@ -266,7 +302,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load autocomplete:', error);
|
console.error('Failed to load autocomplete:', error);
|
||||||
// 실패해도 사용자에게 별도 알림 없이 조용히 처리
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingAutoDescription(false);
|
setIsLoadingAutoDescription(false);
|
||||||
}
|
}
|
||||||
@ -839,6 +874,16 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{uploadProgressModalElement}
|
{uploadProgressModalElement}
|
||||||
|
{tutorial.isActive && (
|
||||||
|
<TutorialOverlay
|
||||||
|
hints={tutorial.hints}
|
||||||
|
currentIndex={tutorial.currentHintIndex}
|
||||||
|
onNext={tutorial.nextHint}
|
||||||
|
onPrev={tutorial.prevHint}
|
||||||
|
onSkip={tutorial.skipTutorial}
|
||||||
|
groupProgress={tutorial.groupProgress}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
314
src/components/Tutorial/TutorialOverlay.tsx
Normal file
314
src/components/Tutorial/TutorialOverlay.tsx
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { TutorialHint } from './tutorialSteps';
|
||||||
|
|
||||||
|
interface Rect {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
bottom: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TooltipPos {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TutorialOverlayProps {
|
||||||
|
hints: TutorialHint[];
|
||||||
|
currentIndex: number;
|
||||||
|
onNext: () => void;
|
||||||
|
onPrev: () => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
groupProgress?: { groupTotal: number; groupOffset: number; isLastKeyInGroup: boolean } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PADDING = 8;
|
||||||
|
|
||||||
|
function getTargetRect(selector: string): Rect | null {
|
||||||
|
const els = Array.from(document.querySelectorAll(selector));
|
||||||
|
const el = els.find(e => {
|
||||||
|
const r = (e as HTMLElement).getBoundingClientRect();
|
||||||
|
return r.width > 0 && r.height > 0;
|
||||||
|
}) ?? els[0];
|
||||||
|
if (!el) return null;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return { top: r.top, left: r.left, width: r.width, height: r.height, bottom: r.bottom };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSpotlightRect(
|
||||||
|
rect: Rect,
|
||||||
|
padding: number,
|
||||||
|
override?: { top?: number; right?: number; bottom?: number; left?: number }
|
||||||
|
): Rect {
|
||||||
|
const pTop = override?.top ?? padding;
|
||||||
|
const pRight = override?.right ?? padding;
|
||||||
|
const pBottom = override?.bottom ?? padding;
|
||||||
|
const pLeft = override?.left ?? padding;
|
||||||
|
const left = Math.max(0, Math.floor(rect.left - pLeft));
|
||||||
|
const top = Math.max(0, Math.floor(rect.top - pTop));
|
||||||
|
const right = Math.min(window.innerWidth, Math.ceil(rect.left + rect.width + pRight));
|
||||||
|
const bottom = Math.min(window.innerHeight, Math.ceil(rect.top + rect.height + pBottom));
|
||||||
|
|
||||||
|
return {
|
||||||
|
top,
|
||||||
|
bottom,
|
||||||
|
left,
|
||||||
|
width: Math.max(0, right - left),
|
||||||
|
height: Math.max(0, bottom - top),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcTooltipPos(rect: Rect, position: TutorialHint['position'], tooltipW = 300, tooltipH = 160): TooltipPos {
|
||||||
|
|
||||||
|
switch (position) {
|
||||||
|
case 'bottom':
|
||||||
|
return {
|
||||||
|
top: Math.min(rect.top + rect.height + PADDING, window.innerHeight - tooltipH - 8),
|
||||||
|
left: Math.min(Math.max(rect.left + rect.width / 2 - tooltipW / 2, 8), window.innerWidth - tooltipW - 8),
|
||||||
|
};
|
||||||
|
case 'top':
|
||||||
|
return {
|
||||||
|
top: Math.max(rect.top - tooltipH - PADDING, 8),
|
||||||
|
left: Math.min(Math.max(rect.left + rect.width / 2 - tooltipW / 2, 8), window.innerWidth - tooltipW - 8),
|
||||||
|
};
|
||||||
|
case 'right':
|
||||||
|
return {
|
||||||
|
top: Math.min(Math.max(rect.top + rect.height / 2 - tooltipH / 2, 8), window.innerHeight - tooltipH - 8),
|
||||||
|
left: Math.min(rect.left + rect.width + PADDING, window.innerWidth - tooltipW - 8),
|
||||||
|
};
|
||||||
|
case 'left':
|
||||||
|
return {
|
||||||
|
top: Math.min(Math.max(rect.top + rect.height / 2 - tooltipH / 2, 8), window.innerHeight - tooltipH - 8),
|
||||||
|
left: Math.max(rect.left - tooltipW - PADDING, 8),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TutorialOverlay: React.FC<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
377
src/components/Tutorial/tutorialSteps.ts
Normal file
377
src/components/Tutorial/tutorialSteps.ts
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
export interface TutorialHint {
|
||||||
|
targetSelector: string;
|
||||||
|
titleKey: string;
|
||||||
|
descriptionKey: string;
|
||||||
|
position: 'top' | 'bottom' | 'left' | 'right';
|
||||||
|
noteKey?: string;
|
||||||
|
variant?: 'bubble';
|
||||||
|
clickToAdvance?: boolean;
|
||||||
|
advanceSelector?: string;
|
||||||
|
noSpotlight?: boolean;
|
||||||
|
spotlightPadding?: number;
|
||||||
|
spotlightPaddingOverride?: { top?: number; right?: number; bottom?: number; left?: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TutorialStepDef {
|
||||||
|
key: string;
|
||||||
|
hints: TutorialHint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TUTORIAL_KEYS = {
|
||||||
|
LANDING: 'landing',
|
||||||
|
ANALYSIS: 'analysis',
|
||||||
|
ASSET: 'asset',
|
||||||
|
SOUND: 'sound',
|
||||||
|
SOUND_LYRICS: 'soundLyrics',
|
||||||
|
SOUND_AUDIO: 'soundAudio',
|
||||||
|
GENERATING:'generating',
|
||||||
|
COMPLETION: 'completion',
|
||||||
|
MY_INFO: 'myInfo',
|
||||||
|
ADO2_CONTENTS: 'ado2Contents',
|
||||||
|
UPLOAD_MODAL: 'uploadModal',
|
||||||
|
UPLOAD_FORM: 'uploadForm',
|
||||||
|
DASHBOARD: 'dashboard',
|
||||||
|
CONTENT_CALENDAR: 'contentCalendar',
|
||||||
|
FEEDBACK: 'feedback',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// 같은 페이지에 속하는 튜토리얼 키 그룹 — 진행 카운터를 합산해서 표시
|
||||||
|
export const TUTORIAL_PAGE_GROUPS: string[][] = [
|
||||||
|
[TUTORIAL_KEYS.SOUND, TUTORIAL_KEYS.SOUND_LYRICS, TUTORIAL_KEYS.SOUND_AUDIO],
|
||||||
|
[TUTORIAL_KEYS.GENERATING, TUTORIAL_KEYS.COMPLETION],
|
||||||
|
[TUTORIAL_KEYS.UPLOAD_MODAL, TUTORIAL_KEYS.UPLOAD_FORM],
|
||||||
|
];
|
||||||
|
|
||||||
|
export const tutorialSteps: TutorialStepDef[] = [
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.LANDING,
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
targetSelector: '.hero-input-wrapper',
|
||||||
|
titleKey: 'tutorial.landing.field.title',
|
||||||
|
descriptionKey: 'tutorial.landing.field.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: false,
|
||||||
|
noSpotlight: true,
|
||||||
|
variant: 'bubble',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.hero-manual-card-title',
|
||||||
|
titleKey: 'tutorial.landing.manual.title',
|
||||||
|
descriptionKey: 'tutorial.landing.manual.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: false,
|
||||||
|
noSpotlight: true,
|
||||||
|
variant: 'bubble',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.hero-button',
|
||||||
|
titleKey: 'tutorial.landing.button.title',
|
||||||
|
descriptionKey: 'tutorial.landing.button.desc',
|
||||||
|
position: 'bottom',
|
||||||
|
clickToAdvance: true,
|
||||||
|
noSpotlight: true,
|
||||||
|
variant: 'bubble',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.ASSET,
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
targetSelector: '.asset-column.asset-column-left',
|
||||||
|
titleKey: 'tutorial.asset.image.title',
|
||||||
|
descriptionKey: 'tutorial.asset.image.desc',
|
||||||
|
position: 'left',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.asset-upload-zone, .asset-mobile-upload-btn',
|
||||||
|
titleKey: 'tutorial.asset.upload.title',
|
||||||
|
descriptionKey: 'tutorial.asset.upload.desc',
|
||||||
|
position: 'bottom',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.asset-ratio-section',
|
||||||
|
titleKey: 'tutorial.asset.ratio.title',
|
||||||
|
descriptionKey: 'tutorial.asset.ratio.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.asset-next-button',
|
||||||
|
titleKey: 'tutorial.asset.next.title',
|
||||||
|
descriptionKey: 'tutorial.asset.next.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.SOUND,
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
targetSelector: '.genre-grid',
|
||||||
|
titleKey: 'tutorial.sound.genre.title',
|
||||||
|
descriptionKey: 'tutorial.sound.genre.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.language-grid',
|
||||||
|
titleKey: 'tutorial.sound.language.title',
|
||||||
|
descriptionKey: 'tutorial.sound.language.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.btn-generate-sound',
|
||||||
|
titleKey: 'tutorial.sound.generate.title',
|
||||||
|
descriptionKey: 'tutorial.sound.generate.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.SOUND_LYRICS,
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
targetSelector: '.lyrics-display',
|
||||||
|
titleKey: 'tutorial.sound.lyrics.title',
|
||||||
|
descriptionKey: 'tutorial.sound.lyrics.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.status-message-new',
|
||||||
|
titleKey: 'tutorial.sound.lyricsWait.title',
|
||||||
|
descriptionKey: 'tutorial.sound.lyricsWait.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.SOUND_AUDIO,
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
targetSelector: '.audio-player',
|
||||||
|
titleKey: 'tutorial.sound.audioPlayer.title',
|
||||||
|
descriptionKey: 'tutorial.sound.audioPlayer.desc',
|
||||||
|
position: 'bottom',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.btn-video-generate',
|
||||||
|
titleKey: 'tutorial.sound.video.title',
|
||||||
|
descriptionKey: 'tutorial.sound.video.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.MY_INFO,
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
targetSelector: '.youtube-connect-section',
|
||||||
|
titleKey: 'tutorial.myInfo.myInfo.title',
|
||||||
|
descriptionKey: 'tutorial.myInfo.myInfo.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.myinfo-social-btn',
|
||||||
|
titleKey: 'tutorial.myInfo.connect.title',
|
||||||
|
descriptionKey: 'tutorial.myInfo.connect.desc',
|
||||||
|
noteKey: 'tutorial.myInfo.connect.note',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.myinfo-connected-accounts',
|
||||||
|
titleKey: 'tutorial.myInfo.connected.title',
|
||||||
|
descriptionKey: 'tutorial.myInfo.connected.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '#sidebar-ado2-contents',
|
||||||
|
titleKey: 'tutorial.myInfo.ado2.title',
|
||||||
|
descriptionKey: 'tutorial.myInfo.ado2.desc',
|
||||||
|
position: 'right',
|
||||||
|
clickToAdvance: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.ADO2_CONTENTS,
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
targetSelector: '.ado2-content-card',
|
||||||
|
titleKey: 'tutorial.ado2.list.title',
|
||||||
|
descriptionKey: 'tutorial.ado2.list.desc',
|
||||||
|
position: 'right',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.content-upload-btn',
|
||||||
|
titleKey: 'tutorial.ado2.download.title',
|
||||||
|
descriptionKey: 'tutorial.ado2.download.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.content-delete-btn',
|
||||||
|
titleKey: 'tutorial.ado2.delete.title',
|
||||||
|
descriptionKey: 'tutorial.ado2.delete.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.content-download-btn',
|
||||||
|
titleKey: 'tutorial.ado2.upload.title',
|
||||||
|
descriptionKey: 'tutorial.ado2.upload.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.GENERATING,
|
||||||
|
hints:[
|
||||||
|
{
|
||||||
|
targetSelector: '.comp2-info-section',
|
||||||
|
titleKey: 'tutorial.completion.contentInfo.title',
|
||||||
|
descriptionKey: 'tutorial.completion.contentInfo.desc',
|
||||||
|
position: 'left',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.comp2-video-section',
|
||||||
|
titleKey: 'tutorial.completion.generating.title',
|
||||||
|
descriptionKey: 'tutorial.completion.generating.desc',
|
||||||
|
position: 'top',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.COMPLETION,
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
targetSelector: '.comp2-video-section',
|
||||||
|
titleKey: 'tutorial.completion.completion.title',
|
||||||
|
descriptionKey: 'tutorial.completion.completion.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '#sidebar-my-info',
|
||||||
|
titleKey: 'tutorial.completion.myInfo.title',
|
||||||
|
descriptionKey: 'tutorial.completion.myInfo.desc',
|
||||||
|
position: 'right',
|
||||||
|
clickToAdvance: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.UPLOAD_MODAL,
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
targetSelector: '.social-posting-content',
|
||||||
|
titleKey: 'tutorial.upload.seo.title',
|
||||||
|
descriptionKey: 'tutorial.upload.seo.desc',
|
||||||
|
position: 'right',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.UPLOAD_FORM,
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
targetSelector: '.social-posting-form',
|
||||||
|
titleKey: 'tutorial.upload.required.title',
|
||||||
|
descriptionKey: 'tutorial.upload.required.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.social-posting-radio-group',
|
||||||
|
titleKey: 'tutorial.upload.schedule.title',
|
||||||
|
descriptionKey: 'tutorial.upload.schedule.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.social-posting-btn:not(.cancel)',
|
||||||
|
titleKey: 'tutorial.upload.submit.title',
|
||||||
|
descriptionKey: 'tutorial.upload.submit.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.DASHBOARD,
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
targetSelector: '.stats-grid-8',
|
||||||
|
titleKey: 'tutorial.dashboard.metrics.title',
|
||||||
|
descriptionKey: 'tutorial.dashboard.metrics.desc',
|
||||||
|
position: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.yoy-chart-card',
|
||||||
|
titleKey: 'tutorial.dashboard.chart.title',
|
||||||
|
descriptionKey: 'tutorial.dashboard.chart.desc',
|
||||||
|
position: 'top',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.tutorial-center-anchor',
|
||||||
|
titleKey: 'tutorial.dashboard.more.title',
|
||||||
|
descriptionKey: 'tutorial.dashboard.more.desc',
|
||||||
|
position: 'bottom',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.CONTENT_CALENDAR,
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
targetSelector: '.calendar-grid-area',
|
||||||
|
titleKey: 'tutorial.contentCalendar.grid.title',
|
||||||
|
descriptionKey: 'tutorial.contentCalendar.grid.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.calendar-side-panel',
|
||||||
|
titleKey: 'tutorial.contentCalendar.panel.title',
|
||||||
|
descriptionKey: 'tutorial.contentCalendar.panel.desc',
|
||||||
|
position: 'left',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.FEEDBACK,
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
targetSelector: '.tutorial-center-anchor',
|
||||||
|
titleKey: 'tutorial.feedback.complete.title',
|
||||||
|
descriptionKey: 'tutorial.feedback.complete.desc',
|
||||||
|
position: 'bottom',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.sidebar-inquiry-btn',
|
||||||
|
titleKey: 'tutorial.feedback.title',
|
||||||
|
descriptionKey: 'tutorial.feedback.desc',
|
||||||
|
position: 'right',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
220
src/components/Tutorial/useTutorial.ts
Normal file
220
src/components/Tutorial/useTutorial.ts
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { tutorialSteps, TutorialHint, TUTORIAL_PAGE_GROUPS } from './tutorialSteps';
|
||||||
|
|
||||||
|
// 현재 키가 속한 그룹의 전체 힌트 수와 현재까지의 offset 반환
|
||||||
|
function getGroupProgress(key: string, currentIndex: number): { groupTotal: number; groupOffset: number; isLastKeyInGroup: boolean } | null {
|
||||||
|
const group = TUTORIAL_PAGE_GROUPS.find(g => g.includes(key));
|
||||||
|
if (!group) return null;
|
||||||
|
let offset = 0;
|
||||||
|
let total = 0;
|
||||||
|
for (const k of group) {
|
||||||
|
const step = tutorialSteps.find(s => s.key === k);
|
||||||
|
const count = step?.hints.length ?? 0;
|
||||||
|
if (k === key) offset = total;
|
||||||
|
total += count;
|
||||||
|
}
|
||||||
|
const isLastKeyInGroup = group[group.length - 1] === key;
|
||||||
|
return { groupTotal: total, groupOffset: offset + currentIndex, isLastKeyInGroup };
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEEN_KEY = 'ado2_tutorial_seen';
|
||||||
|
const PROGRESS_KEY = 'ado2_tutorial_progress';
|
||||||
|
const ENABLED_KEY = 'ado2_tutorial_enabled';
|
||||||
|
|
||||||
|
// 전역 단일 활성 튜토리얼 관리 — 새 튜토리얼 시작 시 이전 것을 skip 처리
|
||||||
|
let globalSkip: (() => void) | null = null;
|
||||||
|
|
||||||
|
function getSeenKeys(): string[] {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(SEEN_KEY) || '[]');
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markSeen(key: string) {
|
||||||
|
const seen = getSeenKeys();
|
||||||
|
if (!seen.includes(key)) {
|
||||||
|
localStorage.setItem(SEEN_KEY, JSON.stringify([...seen, key]));
|
||||||
|
}
|
||||||
|
clearProgress(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveProgress(key: string, index: number) {
|
||||||
|
try {
|
||||||
|
const progress = JSON.parse(localStorage.getItem(PROGRESS_KEY) || '{}');
|
||||||
|
progress[key] = index;
|
||||||
|
localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadProgress(key: string): number {
|
||||||
|
try {
|
||||||
|
const progress = JSON.parse(localStorage.getItem(PROGRESS_KEY) || '{}');
|
||||||
|
return progress[key] ?? 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearProgress(key: string) {
|
||||||
|
try {
|
||||||
|
const progress = JSON.parse(localStorage.getItem(PROGRESS_KEY) || '{}');
|
||||||
|
delete progress[key];
|
||||||
|
localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseTutorialReturn {
|
||||||
|
isActive: boolean;
|
||||||
|
isEnabled: boolean;
|
||||||
|
isRestartPopupVisible: boolean;
|
||||||
|
currentHintIndex: number;
|
||||||
|
hints: TutorialHint[];
|
||||||
|
tutorialKey: string | null;
|
||||||
|
groupProgress: { groupTotal: number; groupOffset: number; isLastKeyInGroup: boolean } | null;
|
||||||
|
startTutorial: (key: string, onComplete?: () => void, forceFromStart?: boolean) => void;
|
||||||
|
nextHint: () => void;
|
||||||
|
prevHint: () => void;
|
||||||
|
skipTutorial: () => void;
|
||||||
|
toggleTutorial: (currentKey: string | null) => void;
|
||||||
|
showRestartPopup: (key: string) => void;
|
||||||
|
confirmRestart: () => void;
|
||||||
|
cancelRestart: () => void;
|
||||||
|
hasSeen: (key: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTutorial(): UseTutorialReturn {
|
||||||
|
const [isActive, setIsActive] = useState(false);
|
||||||
|
const [isEnabled, setIsEnabled] = useState(() => localStorage.getItem(ENABLED_KEY) !== 'false');
|
||||||
|
const [isRestartPopupVisible, setIsRestartPopupVisible] = useState(false);
|
||||||
|
const [pendingRestartKey, setPendingRestartKey] = useState<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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -7,8 +7,11 @@ import {
|
|||||||
deleteComment,
|
deleteComment,
|
||||||
toggleVideoLike,
|
toggleVideoLike,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
|
getUserMe,
|
||||||
|
API_URL,
|
||||||
} from '../utils/api';
|
} from '../utils/api';
|
||||||
import { VideoDetailItem, CommentItem } from '../types/api';
|
import { VideoDetailItem, CommentItem, UserMeResponse } from '../types/api';
|
||||||
|
import { buildVideoShareUrl, tryNativeShare } from '../utils/nativeShare';
|
||||||
import LoginPromptModal from './LoginPromptModal';
|
import LoginPromptModal from './LoginPromptModal';
|
||||||
|
|
||||||
interface VideoDetailContentProps {
|
interface VideoDetailContentProps {
|
||||||
@ -30,7 +33,6 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
|
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [shareMenuOpen, setShareMenuOpen] = useState(false);
|
const [shareMenuOpen, setShareMenuOpen] = useState(false);
|
||||||
const [shareMenuUpward, setShareMenuUpward] = useState(false);
|
|
||||||
const [isLandscape, setIsLandscape] = useState(false);
|
const [isLandscape, setIsLandscape] = useState(false);
|
||||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||||
|
|
||||||
@ -43,16 +45,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
const [commentSubmitting, setCommentSubmitting] = useState(false);
|
const [commentSubmitting, setCommentSubmitting] = useState(false);
|
||||||
const commentTextareaRef = useRef<HTMLTextAreaElement>(null);
|
const commentTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
const [commentNickname, setCommentNickname] = useState('');
|
const [currentUser, setCurrentUser] = useState<UserMeResponse | null>(null);
|
||||||
const [commentAvatarSeedIdx, setCommentAvatarSeedIdx] = useState(0);
|
|
||||||
|
|
||||||
// 고정 seed 목록: 브라우저가 캐싱하여 중복 요청 없음
|
|
||||||
const AVATAR_SEEDS = ['42', '77', '123', '256', '512', '888', '1024', '2048', '3141', '9999'];
|
|
||||||
const commentAvatarSeed = AVATAR_SEEDS[commentAvatarSeedIdx % AVATAR_SEEDS.length];
|
|
||||||
|
|
||||||
const handleChangeAvatar = useCallback(() => {
|
|
||||||
setCommentAvatarSeedIdx(prev => (prev + 1) % AVATAR_SEEDS.length);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchComments = useCallback(async (page: number, append = false) => {
|
const fetchComments = useCallback(async (page: number, append = false) => {
|
||||||
setCommentsLoading(true);
|
setCommentsLoading(true);
|
||||||
@ -93,6 +86,13 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
fetchComments(1);
|
fetchComments(1);
|
||||||
}, [videoId, fetchComments]);
|
}, [videoId, fetchComments]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authed) return;
|
||||||
|
getUserMe().then(setCurrentUser).catch((err) => {
|
||||||
|
console.error('Failed to fetch current user:', err);
|
||||||
|
});
|
||||||
|
}, [authed]);
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return t('videoDetail.dateFormat', { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate() });
|
return t('videoDetail.dateFormat', { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate() });
|
||||||
@ -103,7 +103,9 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`;
|
return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const shareUrl = `${window.location.origin}/video/${videoId}`;
|
const shareUrl = buildVideoShareUrl(API_URL, videoId);
|
||||||
|
const shareTitle = video?.store_name ?? t('videoDetail.kakaoDefaultTitle');
|
||||||
|
const shareDescription = t('videoDetail.kakaoDescription', { region: video?.region ?? '' });
|
||||||
|
|
||||||
const handleCopyLink = async () => {
|
const handleCopyLink = async () => {
|
||||||
try {
|
try {
|
||||||
@ -115,6 +117,18 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleShareButtonClick = async () => {
|
||||||
|
const handled = await tryNativeShare({
|
||||||
|
title: shareTitle,
|
||||||
|
text: shareDescription,
|
||||||
|
url: shareUrl,
|
||||||
|
});
|
||||||
|
if (handled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShareMenuOpen(v => !v);
|
||||||
|
};
|
||||||
|
|
||||||
const handleKakaoShare = () => {
|
const handleKakaoShare = () => {
|
||||||
const kakao = window.Kakao;
|
const kakao = window.Kakao;
|
||||||
if (kakao?.Share) {
|
if (kakao?.Share) {
|
||||||
@ -159,23 +173,6 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
}, [shareMenuOpen]);
|
}, [shareMenuOpen]);
|
||||||
|
|
||||||
// 모달 내부는 스크롤 영역(.video-detail-body)이 곧 화면 경계라, 버튼이 그 하단에
|
|
||||||
// 가까우면 아래로 펼치는 메뉴가 잘려서 안 보인다. 열기 직전 남은 공간을 재서 뒤집는다.
|
|
||||||
const handleToggleShareMenu = () => {
|
|
||||||
if (!shareMenuOpen) {
|
|
||||||
const btnRect = shareMenuRef.current?.getBoundingClientRect();
|
|
||||||
if (btnRect) {
|
|
||||||
const scrollBox = shareMenuRef.current?.closest('.video-detail-body');
|
|
||||||
const boundaryBottom = scrollBox
|
|
||||||
? scrollBox.getBoundingClientRect().bottom
|
|
||||||
: window.innerHeight;
|
|
||||||
const ESTIMATED_MENU_HEIGHT = 220;
|
|
||||||
setShareMenuUpward(btnRect.bottom + ESTIMATED_MENU_HEIGHT > boundaryBottom);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setShareMenuOpen(v => !v);
|
|
||||||
};
|
|
||||||
|
|
||||||
const likeDebounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
const likeDebounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const handleLike = () => {
|
const handleLike = () => {
|
||||||
@ -215,13 +212,11 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
if (!commentInput.trim() || commentSubmitting) return;
|
if (!commentInput.trim() || commentSubmitting) return;
|
||||||
setCommentSubmitting(true);
|
setCommentSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await postVideoComment(videoId, commentInput.trim(), commentNickname);
|
await postVideoComment(videoId, commentInput.trim());
|
||||||
setCommentInput('');
|
setCommentInput('');
|
||||||
if (commentTextareaRef.current) {
|
if (commentTextareaRef.current) {
|
||||||
commentTextareaRef.current.style.height = 'auto';
|
commentTextareaRef.current.style.height = 'auto';
|
||||||
}
|
}
|
||||||
setCommentNickname('');
|
|
||||||
setCommentAvatarSeedIdx(prev => (prev + 1) % AVATAR_SEEDS.length);
|
|
||||||
await fetchComments(1);
|
await fetchComments(1);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to post comment:', err);
|
console.error('Failed to post comment:', err);
|
||||||
@ -310,7 +305,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
<div style={{ position: 'relative' }} ref={shareMenuRef}>
|
<div style={{ position: 'relative' }} ref={shareMenuRef}>
|
||||||
<button
|
<button
|
||||||
className="video-detail-copy-btn"
|
className="video-detail-copy-btn"
|
||||||
onClick={handleToggleShareMenu}
|
onClick={handleShareButtonClick}
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
||||||
@ -319,7 +314,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
{copied ? t('videoDetail.copied') : t('videoDetail.share')}
|
{copied ? t('videoDetail.copied') : t('videoDetail.share')}
|
||||||
</button>
|
</button>
|
||||||
{shareMenuOpen && (
|
{shareMenuOpen && (
|
||||||
<div className={`video-detail-share-menu ${shareMenuUpward ? 'upward' : ''}`}>
|
<div className="video-detail-share-menu">
|
||||||
{/* 카카오톡 */}
|
{/* 카카오톡 */}
|
||||||
<button className="video-detail-share-item" onClick={handleKakaoShare}>
|
<button className="video-detail-share-item" onClick={handleKakaoShare}>
|
||||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
@ -362,25 +357,17 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
<span className="video-detail-comments-count">{commentsTotal}</span>
|
<span className="video-detail-comments-count">{commentsTotal}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 댓글 작성자 프로필 선택 */}
|
{/* 댓글 작성자 프로필 (카카오 로그인 정보) */}
|
||||||
{authed && (
|
{authed && currentUser && (
|
||||||
<div className="video-detail-comment-profile">
|
<div className="video-detail-comment-profile">
|
||||||
<img
|
{currentUser.profile_image_url && (
|
||||||
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${commentAvatarSeed}`}
|
<img
|
||||||
alt={t('videoDetail.changeAvatarTitle')}
|
src={currentUser.profile_image_url}
|
||||||
className="video-detail-comment-avatar"
|
alt={currentUser.nickname}
|
||||||
onClick={handleChangeAvatar}
|
className="video-detail-comment-avatar"
|
||||||
style={{ cursor: 'pointer' }}
|
/>
|
||||||
title={t('videoDetail.changeAvatarTitle')}
|
)}
|
||||||
/>
|
<span className="video-detail-comment-nickname">{currentUser.nickname}</span>
|
||||||
<input
|
|
||||||
className="video-detail-nickname-input"
|
|
||||||
type="text"
|
|
||||||
placeholder={t('videoDetail.nicknamePlaceholder')}
|
|
||||||
value={commentNickname}
|
|
||||||
onChange={(e) => setCommentNickname(e.target.value)}
|
|
||||||
maxLength={20}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -421,11 +408,13 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
<ul className="video-detail-comment-list">
|
<ul className="video-detail-comment-list">
|
||||||
{comments.map((c) => (
|
{comments.map((c) => (
|
||||||
<li key={c.id} className="video-detail-comment-item">
|
<li key={c.id} className="video-detail-comment-item">
|
||||||
<img
|
{c.profile_image_url && (
|
||||||
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${c.id}`}
|
<img
|
||||||
alt="avatar"
|
src={c.profile_image_url}
|
||||||
className="video-detail-comment-avatar"
|
alt={c.nickname || t('videoDetail.anonymous')}
|
||||||
/>
|
className="video-detail-comment-avatar"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="video-detail-comment-body">
|
<div className="video-detail-comment-body">
|
||||||
<span className="video-detail-comment-nickname">
|
<span className="video-detail-comment-nickname">
|
||||||
{c.nickname || t('videoDetail.anonymous')}
|
{c.nickname || t('videoDetail.anonymous')}
|
||||||
@ -450,11 +439,13 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
|||||||
<ul className="video-detail-reply-list">
|
<ul className="video-detail-reply-list">
|
||||||
{c.replies.map((r) => (
|
{c.replies.map((r) => (
|
||||||
<li key={r.id} className="video-detail-reply-item">
|
<li key={r.id} className="video-detail-reply-item">
|
||||||
<img
|
{r.profile_image_url && (
|
||||||
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${r.id}`}
|
<img
|
||||||
alt="avatar"
|
src={r.profile_image_url}
|
||||||
className="video-detail-comment-avatar small"
|
alt={r.nickname || t('videoDetail.anonymous')}
|
||||||
/>
|
className="video-detail-comment-avatar small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="video-detail-comment-body">
|
<div className="video-detail-comment-body">
|
||||||
<span className="video-detail-comment-nickname">
|
<span className="video-detail-comment-nickname">
|
||||||
{r.nickname || t('videoDetail.anonymous')}
|
{r.nickname || t('videoDetail.anonymous')}
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"newProject": "New Project",
|
"newProject": "Create New Project",
|
||||||
"ado2Contents": "ADO2 Contents",
|
"ado2Contents": "ADO2 Contents",
|
||||||
"myContents": "My Contents",
|
"myContents": "My Contents",
|
||||||
"myInfo": "My Info",
|
"myInfo": "My Info",
|
||||||
@ -21,9 +21,197 @@
|
|||||||
"credits": "Credits left: {{count}}",
|
"credits": "Credits left: {{count}}",
|
||||||
"loggingOut": "Logging out...",
|
"loggingOut": "Logging out...",
|
||||||
"logout": "Log Out",
|
"logout": "Log Out",
|
||||||
|
"tutorialRestart": "Restart Tutorial",
|
||||||
|
"tutorial": "Tutorial",
|
||||||
|
"tutorialOn": "Enable Tutorial",
|
||||||
|
"tutorialOff": "Disable Tutorial",
|
||||||
|
"inquiry": "Feedback",
|
||||||
"login": "Log In",
|
"login": "Log In",
|
||||||
"settings": "Settings",
|
"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": {
|
"footer": {
|
||||||
"company": "O2O Inc.",
|
"company": "O2O Inc.",
|
||||||
@ -158,29 +346,6 @@
|
|||||||
"loggingIn": "Logging in...",
|
"loggingIn": "Logging in...",
|
||||||
"kakaoStart": "Start with Kakao"
|
"kakaoStart": "Start with Kakao"
|
||||||
},
|
},
|
||||||
"pipelineTabs": {
|
|
||||||
"ariaLabel": "Content type",
|
|
||||||
"ado2": "ADO2",
|
|
||||||
"ssul": "Ssulbox"
|
|
||||||
},
|
|
||||||
"ssulbox": {
|
|
||||||
"steps": {
|
|
||||||
"pick": "Select",
|
|
||||||
"making": "Generating",
|
|
||||||
"done": "Done"
|
|
||||||
},
|
|
||||||
"create": {
|
|
||||||
"step1": "STEP 1 · Pick a story",
|
|
||||||
"step2": "STEP 2 · Pick a place",
|
|
||||||
"searchPlaceholder": "Business name, or paste a Naver Map link",
|
|
||||||
"searchButton": "Search",
|
|
||||||
"searching": "Searching",
|
|
||||||
"cost": "Cost",
|
|
||||||
"costValue": "1 credit",
|
|
||||||
"submit": "Create Ssulbox",
|
|
||||||
"submitting": "Requesting…"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"urlInput": {
|
"urlInput": {
|
||||||
"searchTypeBusinessName": "Business Name",
|
"searchTypeBusinessName": "Business Name",
|
||||||
"placeholderBusinessName": "Enter a business name",
|
"placeholderBusinessName": "Enter a business name",
|
||||||
@ -429,10 +594,10 @@
|
|||||||
},
|
},
|
||||||
"myInfo": {
|
"myInfo": {
|
||||||
"title": "My Info",
|
"title": "My Info",
|
||||||
"tabContents": "My Contents",
|
"tabBasic": "Basic Info",
|
||||||
"tabCalendar": "Calendar",
|
"tabPayment": "Payment Info",
|
||||||
"tabPayment": "Payment",
|
"tabBusiness": "Social Channel Management",
|
||||||
"tabBusiness": "SNS",
|
"basicPlaceholder": "Basic information settings are coming soon.",
|
||||||
"paymentPlaceholder": "Payment information settings are coming soon.",
|
"paymentPlaceholder": "Payment information settings are coming soon.",
|
||||||
"myBusiness": "My Business",
|
"myBusiness": "My Business",
|
||||||
"noBusinessTitle": "No registered business yet",
|
"noBusinessTitle": "No registered business yet",
|
||||||
@ -461,7 +626,9 @@
|
|||||||
"chargeSubmit": "Submit",
|
"chargeSubmit": "Submit",
|
||||||
"chargeSubmitting": "Submitting...",
|
"chargeSubmitting": "Submitting...",
|
||||||
"chargeSuccess": "Your top-up request has been submitted!",
|
"chargeSuccess": "Your top-up request has been submitted!",
|
||||||
"chargeConfirm": "OK"
|
"chargeConfirm": "OK",
|
||||||
|
"tabContents": "My Contents",
|
||||||
|
"tabCalendar": "Calendar"
|
||||||
},
|
},
|
||||||
"ado2Contents": {
|
"ado2Contents": {
|
||||||
"title": "ADO2 Contents",
|
"title": "ADO2 Contents",
|
||||||
@ -545,8 +712,29 @@
|
|||||||
"scheduled": "Planned",
|
"scheduled": "Planned",
|
||||||
"failed": "Failed"
|
"failed": "Failed"
|
||||||
},
|
},
|
||||||
"months": ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],
|
"months": [
|
||||||
"days": ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],
|
"Jan",
|
||||||
|
"Feb",
|
||||||
|
"Mar",
|
||||||
|
"Apr",
|
||||||
|
"May",
|
||||||
|
"Jun",
|
||||||
|
"Jul",
|
||||||
|
"Aug",
|
||||||
|
"Sep",
|
||||||
|
"Oct",
|
||||||
|
"Nov",
|
||||||
|
"Dec"
|
||||||
|
],
|
||||||
|
"days": [
|
||||||
|
"Sun",
|
||||||
|
"Mon",
|
||||||
|
"Tue",
|
||||||
|
"Wed",
|
||||||
|
"Thu",
|
||||||
|
"Fri",
|
||||||
|
"Sat"
|
||||||
|
],
|
||||||
"yearMonth": "{{month}} {{year}}",
|
"yearMonth": "{{month}} {{year}}",
|
||||||
"monthDay": "{{month}} {{day}}",
|
"monthDay": "{{month}} {{day}}",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
@ -564,14 +752,13 @@
|
|||||||
},
|
},
|
||||||
"loginPrompt": {
|
"loginPrompt": {
|
||||||
"title": "Login Required",
|
"title": "Login Required",
|
||||||
"description": "Sign in with Kakao in a few seconds\nto keep going.",
|
"loginBtn": "Login",
|
||||||
"loginBtn": "Continue with Kakao"
|
"description": "Sign in with Kakao in a few seconds\nto keep going."
|
||||||
},
|
},
|
||||||
"videoDetail": {
|
"videoDetail": {
|
||||||
"dateFormat": "{{month}}/{{day}}/{{year}}",
|
"dateFormat": "{{month}}/{{day}}/{{year}}",
|
||||||
"kakaoDefaultTitle": "ADO2 Video",
|
"kakaoDefaultTitle": "ADO2 Video",
|
||||||
"kakaoDescription": "{{region}} · ADO2 AI Marketing Video",
|
"kakaoDescription": "{{region}} · ADO2 AI Marketing Video",
|
||||||
"kakaoButtonTitle": "Watch Video",
|
|
||||||
"deletedComment": "(This comment has been deleted.)",
|
"deletedComment": "(This comment has been deleted.)",
|
||||||
"closeAriaLabel": "Close",
|
"closeAriaLabel": "Close",
|
||||||
"share": "Share",
|
"share": "Share",
|
||||||
@ -581,8 +768,6 @@
|
|||||||
"shareTwitter": "X (Twitter)",
|
"shareTwitter": "X (Twitter)",
|
||||||
"copyUrl": "Copy URL",
|
"copyUrl": "Copy URL",
|
||||||
"commentsTitle": "Comments",
|
"commentsTitle": "Comments",
|
||||||
"changeAvatarTitle": "Click to change avatar",
|
|
||||||
"nicknamePlaceholder": "Author name",
|
|
||||||
"commentPlaceholder": "Write a comment...",
|
"commentPlaceholder": "Write a comment...",
|
||||||
"commentLoginRequired": "Please log in to write a comment",
|
"commentLoginRequired": "Please log in to write a comment",
|
||||||
"commentSubmitting": "Submitting",
|
"commentSubmitting": "Submitting",
|
||||||
@ -603,5 +788,28 @@
|
|||||||
"autocompleteError": "No results found. Please check your input and try again.",
|
"autocompleteError": "No results found. Please check your input and try again.",
|
||||||
"autocompleteGeneralError": "An error occurred while retrieving business information. Please try again.",
|
"autocompleteGeneralError": "An error occurred while retrieving business information. Please try again.",
|
||||||
"pageComingSoon": "{{page}} page is coming soon."
|
"pageComingSoon": "{{page}} page is coming soon."
|
||||||
|
},
|
||||||
|
"pipelineTabs": {
|
||||||
|
"ariaLabel": "Content type",
|
||||||
|
"ado2": "ADO2",
|
||||||
|
"ssul": "Ssulbox"
|
||||||
|
},
|
||||||
|
"ssulbox": {
|
||||||
|
"steps": {
|
||||||
|
"pick": "Select",
|
||||||
|
"making": "Generating",
|
||||||
|
"done": "Done"
|
||||||
|
},
|
||||||
|
"create": {
|
||||||
|
"step1": "STEP 1 · Pick a story",
|
||||||
|
"step2": "STEP 2 · Pick a place",
|
||||||
|
"searchPlaceholder": "Business name, or paste a Naver Map link",
|
||||||
|
"searchButton": "Search",
|
||||||
|
"searching": "Searching",
|
||||||
|
"cost": "Cost",
|
||||||
|
"costValue": "1 credit",
|
||||||
|
"submit": "Create Ssulbox",
|
||||||
|
"submitting": "Requesting…"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,12 +21,200 @@
|
|||||||
"credits": "보유 크레딧: {{count}}",
|
"credits": "보유 크레딧: {{count}}",
|
||||||
"loggingOut": "로그아웃 중...",
|
"loggingOut": "로그아웃 중...",
|
||||||
"logout": "로그아웃",
|
"logout": "로그아웃",
|
||||||
|
"tutorialRestart": "튜토리얼 다시 보기",
|
||||||
|
"tutorial": "튜토리얼",
|
||||||
|
"tutorialOn": "튜토리얼 켜기",
|
||||||
|
"tutorialOff": "튜토리얼 끄기",
|
||||||
|
"inquiry": "고객의견",
|
||||||
"login": "로그인",
|
"login": "로그인",
|
||||||
"settings": "설정",
|
"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": {
|
"footer": {
|
||||||
"company":"㈜에이아이오투오",
|
"company": "㈜에이아이오투오",
|
||||||
"businessNumber": "사업자 등록번호 : 620-87-00810 | 대표 : 안성민",
|
"businessNumber": "사업자 등록번호 : 620-87-00810 | 대표 : 안성민",
|
||||||
"headquarters": "본사 : 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호",
|
"headquarters": "본사 : 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호",
|
||||||
"researchCenter": "연구소 : 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)",
|
"researchCenter": "연구소 : 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)",
|
||||||
@ -109,7 +297,8 @@
|
|||||||
"close": "닫기",
|
"close": "닫기",
|
||||||
"doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요.",
|
"doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요.",
|
||||||
"goToCalendar": "캘린더에서 확인",
|
"goToCalendar": "캘린더에서 확인",
|
||||||
"scheduleConflict": "예약 시간이 충돌하여 {{time}}으로 조정되었습니다." },
|
"scheduleConflict": "예약 시간이 충돌하여 {{time}}으로 조정되었습니다."
|
||||||
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"hero": {
|
"hero": {
|
||||||
"searchTypeLabel": "업체명 | URL",
|
"searchTypeLabel": "업체명 | URL",
|
||||||
@ -157,78 +346,6 @@
|
|||||||
"loggingIn": "로그인 중...",
|
"loggingIn": "로그인 중...",
|
||||||
"kakaoStart": "카카오로 시작하기"
|
"kakaoStart": "카카오로 시작하기"
|
||||||
},
|
},
|
||||||
"pipelineTabs": {
|
|
||||||
"ariaLabel": "생성할 콘텐츠 종류",
|
|
||||||
"ado2": "ADO2",
|
|
||||||
"ssul": "썰박스"
|
|
||||||
},
|
|
||||||
"ssulbox": {
|
|
||||||
"steps": {
|
|
||||||
"pick": "선택",
|
|
||||||
"making": "생성",
|
|
||||||
"done": "완성"
|
|
||||||
},
|
|
||||||
"create": {
|
|
||||||
"step1": "STEP 1 · 시나리오 선택",
|
|
||||||
"step2": "STEP 2 · 업장 검색",
|
|
||||||
"searchPlaceholder": "업체명 또는 URL을 입력하세요.",
|
|
||||||
"searchButton": "검색",
|
|
||||||
"searching": "검색 중",
|
|
||||||
"placeLoading": "가게를 찾는 중… (몇 초 걸려요)",
|
|
||||||
"hintUrlOk": "네이버 링크로 이 가게를 직접 크롤링합니다.",
|
|
||||||
"hintDefault": "가게를 선택하면 그 가게의 사진과 정보를 크롤링해 썰박스에 씁니다.",
|
|
||||||
"hintScenario": "{{name}} 시나리오로 약 5~6분 소요됩니다.",
|
|
||||||
"noResult": "검색 결과가 없어요. 이름을 바꾸거나 네이버 지도 링크를 붙여넣어 주세요.",
|
|
||||||
"cost": "생성 비용",
|
|
||||||
"costValue": "1 크레딧",
|
|
||||||
"submit": "썰박스 만들기",
|
|
||||||
"submitting": "요청 중…"
|
|
||||||
},
|
|
||||||
"making": {
|
|
||||||
"title": "썰박스를 만들고 있어요",
|
|
||||||
"step1": "대본 생성 중",
|
|
||||||
"step2": "스토리보드 구성 중",
|
|
||||||
"step3": "이미지·목소리 생성 중",
|
|
||||||
"step4": "영상 합성 중",
|
|
||||||
"takesTime": "약 5~6분 소요됩니다. 다른 작업을 하셔도 계속 진행됩니다.",
|
|
||||||
"failTitle": "생성에 실패했어요",
|
|
||||||
"failDefault": "잠시 후 다시 시도해주세요.",
|
|
||||||
"refunded": "차감된 크레딧은 환불되었습니다.",
|
|
||||||
"retry": "다시 만들기"
|
|
||||||
},
|
|
||||||
"result": {
|
|
||||||
"title": "썰박스가 완성됐어요",
|
|
||||||
"download": "다운로드",
|
|
||||||
"downloading": "다운로드 중...",
|
|
||||||
"loadFailed": "영상을 불러오지 못했어요. 내 콘텐츠에서 확인해주세요.",
|
|
||||||
"scenarioLabel": "시나리오",
|
|
||||||
"createdLabel": "생성일시"
|
|
||||||
},
|
|
||||||
"viewer": {
|
|
||||||
"untitled": "이름 없는 콘텐츠"
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"createFailed": "생성 요청에 실패했어요. 잠시 후 다시 시도해주세요."
|
|
||||||
},
|
|
||||||
"scenario": {
|
|
||||||
"joseon": {
|
|
||||||
"name": "조선왕",
|
|
||||||
"desc": "조선 27대 왕들의 실화 썰"
|
|
||||||
},
|
|
||||||
"samgukji": {
|
|
||||||
"name": "삼국지",
|
|
||||||
"desc": "위·촉·오 영웅 28인의 야사"
|
|
||||||
},
|
|
||||||
"greek": {
|
|
||||||
"name": "그리스·로마신화",
|
|
||||||
"desc": "올림포스 신·영웅들의 전설"
|
|
||||||
},
|
|
||||||
"odyssey": {
|
|
||||||
"name": "오디세이",
|
|
||||||
"desc": "오디세우스 10년 귀향 대모험"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"urlInput": {
|
"urlInput": {
|
||||||
"searchTypeBusinessName": "업체명",
|
"searchTypeBusinessName": "업체명",
|
||||||
"placeholderBusinessName": "업체명을 입력하세요.",
|
"placeholderBusinessName": "업체명을 입력하세요.",
|
||||||
@ -477,10 +594,10 @@
|
|||||||
},
|
},
|
||||||
"myInfo": {
|
"myInfo": {
|
||||||
"title": "내 정보",
|
"title": "내 정보",
|
||||||
"tabContents": "내 콘텐츠",
|
"tabBasic": "기본 정보",
|
||||||
"tabCalendar": "콘텐츠 캘린더",
|
|
||||||
"tabPayment": "결제 정보",
|
"tabPayment": "결제 정보",
|
||||||
"tabBusiness": "소셜 채널 관리",
|
"tabBusiness": "소셜 채널 관리",
|
||||||
|
"basicPlaceholder": "기본 정보 설정 기능이 준비 중입니다.",
|
||||||
"paymentPlaceholder": "결제 정보 설정 기능이 준비 중입니다.",
|
"paymentPlaceholder": "결제 정보 설정 기능이 준비 중입니다.",
|
||||||
"myBusiness": "내 비즈니스",
|
"myBusiness": "내 비즈니스",
|
||||||
"noBusinessTitle": "아직 등록된 비즈니스가 없어요",
|
"noBusinessTitle": "아직 등록된 비즈니스가 없어요",
|
||||||
@ -509,7 +626,9 @@
|
|||||||
"chargeSubmit": "요청하기",
|
"chargeSubmit": "요청하기",
|
||||||
"chargeSubmitting": "요청 중...",
|
"chargeSubmitting": "요청 중...",
|
||||||
"chargeSuccess": "충전 요청이 완료 되었습니다!",
|
"chargeSuccess": "충전 요청이 완료 되었습니다!",
|
||||||
"chargeConfirm": "확인"
|
"chargeConfirm": "확인",
|
||||||
|
"tabContents": "내 콘텐츠",
|
||||||
|
"tabCalendar": "콘텐츠 캘린더"
|
||||||
},
|
},
|
||||||
"ado2Contents": {
|
"ado2Contents": {
|
||||||
"title": "ADO2 콘텐츠",
|
"title": "ADO2 콘텐츠",
|
||||||
@ -593,8 +712,29 @@
|
|||||||
"scheduled": "예약",
|
"scheduled": "예약",
|
||||||
"failed": "실패"
|
"failed": "실패"
|
||||||
},
|
},
|
||||||
"months": ["1월","2월","3월","4월","5월","6월","7월","8월","9월","10월","11월","12월"],
|
"months": [
|
||||||
"days": ["일","월","화","수","목","금","토"],
|
"1월",
|
||||||
|
"2월",
|
||||||
|
"3월",
|
||||||
|
"4월",
|
||||||
|
"5월",
|
||||||
|
"6월",
|
||||||
|
"7월",
|
||||||
|
"8월",
|
||||||
|
"9월",
|
||||||
|
"10월",
|
||||||
|
"11월",
|
||||||
|
"12월"
|
||||||
|
],
|
||||||
|
"days": [
|
||||||
|
"일",
|
||||||
|
"월",
|
||||||
|
"화",
|
||||||
|
"수",
|
||||||
|
"목",
|
||||||
|
"금",
|
||||||
|
"토"
|
||||||
|
],
|
||||||
"yearMonth": "{{year}}년 {{month}}",
|
"yearMonth": "{{year}}년 {{month}}",
|
||||||
"monthDay": "{{month}} {{day}}일",
|
"monthDay": "{{month}} {{day}}일",
|
||||||
"loading": "불러오는 중...",
|
"loading": "불러오는 중...",
|
||||||
@ -612,14 +752,13 @@
|
|||||||
},
|
},
|
||||||
"loginPrompt": {
|
"loginPrompt": {
|
||||||
"title": "로그인이 필요합니다.",
|
"title": "로그인이 필요합니다.",
|
||||||
"description": "카카오 계정으로 간편하게 로그인하고\n이어서 진행해보세요.",
|
"loginBtn": "로그인",
|
||||||
"loginBtn": "카카오 로그인"
|
"description": "카카오 계정으로 간편하게 로그인하고\n이어서 진행해보세요."
|
||||||
},
|
},
|
||||||
"videoDetail": {
|
"videoDetail": {
|
||||||
"dateFormat": "{{year}}년 {{month}}월 {{day}}일",
|
"dateFormat": "{{year}}년 {{month}}월 {{day}}일",
|
||||||
"kakaoDefaultTitle": "ADO2 영상",
|
"kakaoDefaultTitle": "ADO2 영상",
|
||||||
"kakaoDescription": "{{region}} · ADO2 AI 마케팅 영상",
|
"kakaoDescription": "{{region}} · ADO2 AI 마케팅 영상",
|
||||||
"kakaoButtonTitle": "영상 보기",
|
|
||||||
"deletedComment": "(삭제된 댓글입니다.)",
|
"deletedComment": "(삭제된 댓글입니다.)",
|
||||||
"closeAriaLabel": "닫기",
|
"closeAriaLabel": "닫기",
|
||||||
"share": "공유하기",
|
"share": "공유하기",
|
||||||
@ -629,8 +768,6 @@
|
|||||||
"shareTwitter": "X (트위터)",
|
"shareTwitter": "X (트위터)",
|
||||||
"copyUrl": "URL 복사",
|
"copyUrl": "URL 복사",
|
||||||
"commentsTitle": "댓글",
|
"commentsTitle": "댓글",
|
||||||
"changeAvatarTitle": "클릭하여 아바타 변경",
|
|
||||||
"nicknamePlaceholder": "작성자 이름",
|
|
||||||
"commentPlaceholder": "댓글을 입력하세요...",
|
"commentPlaceholder": "댓글을 입력하세요...",
|
||||||
"commentLoginRequired": "로그인 후 댓글을 작성할 수 있습니다",
|
"commentLoginRequired": "로그인 후 댓글을 작성할 수 있습니다",
|
||||||
"commentSubmitting": "작성 중",
|
"commentSubmitting": "작성 중",
|
||||||
@ -651,5 +788,77 @@
|
|||||||
"autocompleteError": "검색 정보를 찾을 수 없습니다. 입력 정보를 다시 확인해주세요.",
|
"autocompleteError": "검색 정보를 찾을 수 없습니다. 입력 정보를 다시 확인해주세요.",
|
||||||
"autocompleteGeneralError": "업체 정보 조회 중 오류가 발생했습니다. 다시 시도해주세요.",
|
"autocompleteGeneralError": "업체 정보 조회 중 오류가 발생했습니다. 다시 시도해주세요.",
|
||||||
"pageComingSoon": "{{page}} 페이지 준비 중입니다."
|
"pageComingSoon": "{{page}} 페이지 준비 중입니다."
|
||||||
|
},
|
||||||
|
"pipelineTabs": {
|
||||||
|
"ariaLabel": "생성할 콘텐츠 종류",
|
||||||
|
"ado2": "ADO2",
|
||||||
|
"ssul": "썰박스"
|
||||||
|
},
|
||||||
|
"ssulbox": {
|
||||||
|
"steps": {
|
||||||
|
"pick": "선택",
|
||||||
|
"making": "생성",
|
||||||
|
"done": "완성"
|
||||||
|
},
|
||||||
|
"create": {
|
||||||
|
"step1": "STEP 1 · 시나리오 선택",
|
||||||
|
"step2": "STEP 2 · 업장 검색",
|
||||||
|
"searchPlaceholder": "업체명 또는 URL을 입력하세요.",
|
||||||
|
"searchButton": "검색",
|
||||||
|
"searching": "검색 중",
|
||||||
|
"placeLoading": "가게를 찾는 중… (몇 초 걸려요)",
|
||||||
|
"hintUrlOk": "네이버 링크로 이 가게를 직접 크롤링합니다.",
|
||||||
|
"hintDefault": "가게를 선택하면 그 가게의 사진과 정보를 크롤링해 썰박스에 씁니다.",
|
||||||
|
"hintScenario": "{{name}} 시나리오로 약 5~6분 소요됩니다.",
|
||||||
|
"noResult": "검색 결과가 없어요. 이름을 바꾸거나 네이버 지도 링크를 붙여넣어 주세요.",
|
||||||
|
"cost": "생성 비용",
|
||||||
|
"costValue": "1 크레딧",
|
||||||
|
"submit": "썰박스 만들기",
|
||||||
|
"submitting": "요청 중…"
|
||||||
|
},
|
||||||
|
"making": {
|
||||||
|
"title": "썰박스를 만들고 있어요",
|
||||||
|
"step1": "대본 생성 중",
|
||||||
|
"step2": "스토리보드 구성 중",
|
||||||
|
"step3": "이미지·목소리 생성 중",
|
||||||
|
"step4": "영상 합성 중",
|
||||||
|
"takesTime": "약 5~6분 소요됩니다. 다른 작업을 하셔도 계속 진행됩니다.",
|
||||||
|
"failTitle": "생성에 실패했어요",
|
||||||
|
"failDefault": "잠시 후 다시 시도해주세요.",
|
||||||
|
"refunded": "차감된 크레딧은 환불되었습니다.",
|
||||||
|
"retry": "다시 만들기"
|
||||||
|
},
|
||||||
|
"result": {
|
||||||
|
"title": "썰박스가 완성됐어요",
|
||||||
|
"download": "다운로드",
|
||||||
|
"downloading": "다운로드 중...",
|
||||||
|
"loadFailed": "영상을 불러오지 못했어요. 내 콘텐츠에서 확인해주세요.",
|
||||||
|
"scenarioLabel": "시나리오",
|
||||||
|
"createdLabel": "생성일시"
|
||||||
|
},
|
||||||
|
"viewer": {
|
||||||
|
"untitled": "이름 없는 콘텐츠"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"createFailed": "생성 요청에 실패했어요. 잠시 후 다시 시도해주세요."
|
||||||
|
},
|
||||||
|
"scenario": {
|
||||||
|
"joseon": {
|
||||||
|
"name": "조선왕",
|
||||||
|
"desc": "조선 27대 왕들의 실화 썰"
|
||||||
|
},
|
||||||
|
"samgukji": {
|
||||||
|
"name": "삼국지",
|
||||||
|
"desc": "위·촉·오 영웅 28인의 야사"
|
||||||
|
},
|
||||||
|
"greek": {
|
||||||
|
"name": "그리스·로마신화",
|
||||||
|
"desc": "올림포스 신·영웅들의 전설"
|
||||||
|
},
|
||||||
|
"odyssey": {
|
||||||
|
"name": "오디세이",
|
||||||
|
"desc": "오디세우스 10년 귀향 대모험"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -163,7 +163,15 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="content-card-thumbnail ado2-gallery-thumbnail-wrap">
|
<div className="content-card-thumbnail ado2-gallery-thumbnail-wrap">
|
||||||
{video.thumbnail_url ? (
|
{video.poster_url ? (
|
||||||
|
<img
|
||||||
|
src={video.poster_url}
|
||||||
|
alt={video.store_name}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
className="content-video-preview"
|
||||||
|
/>
|
||||||
|
) : video.thumbnail_url ? (
|
||||||
<img
|
<img
|
||||||
src={video.thumbnail_url}
|
src={video.thumbnail_url}
|
||||||
alt={video.store_name}
|
alt={video.store_name}
|
||||||
|
|||||||
@ -478,6 +478,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
like_count: 0,
|
like_count: 0,
|
||||||
comment_count: 0,
|
comment_count: 0,
|
||||||
|
is_liked_by_me: false,
|
||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
|
|||||||
@ -79,6 +79,7 @@ interface BusinessInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface GenerationFlowProps {
|
interface GenerationFlowProps {
|
||||||
|
onHome?: () => void;
|
||||||
initialActiveItem?: string;
|
initialActiveItem?: string;
|
||||||
initialImageList?: ImageListItem[];
|
initialImageList?: ImageListItem[];
|
||||||
businessInfo?: BusinessInfo;
|
businessInfo?: BusinessInfo;
|
||||||
@ -94,6 +95,7 @@ interface GenerationFlowProps {
|
|||||||
// 3: 완료 (Completion)
|
// 3: 완료 (Completion)
|
||||||
|
|
||||||
const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
|
onHome,
|
||||||
initialActiveItem = NAV.HOME,
|
initialActiveItem = NAV.HOME,
|
||||||
initialImageList = [],
|
initialImageList = [],
|
||||||
businessInfo,
|
businessInfo,
|
||||||
@ -311,7 +313,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
setImageList(prev => [...newImages, ...prev]);
|
setImageList(prev => [...newImages, ...prev]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 홈 버튼(로고) 클릭 시 모든 상태 초기화 후 ADO2 콘텐츠 갤러리로 이동
|
// 홈 버튼(로고) 클릭 시 프로젝트 상태를 비우고, 부모가 있으면 랜딩으로 돌아간다.
|
||||||
const handleHome = () => {
|
const handleHome = () => {
|
||||||
clearProjectStorage();
|
clearProjectStorage();
|
||||||
localStorage.removeItem(ANALYSIS_DATA_KEY);
|
localStorage.removeItem(ANALYSIS_DATA_KEY);
|
||||||
@ -321,6 +323,10 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
setAnalysisData(null);
|
setAnalysisData(null);
|
||||||
revokeAllPreviews(imageListRef.current);
|
revokeAllPreviews(imageListRef.current);
|
||||||
setImageList([]);
|
setImageList([]);
|
||||||
|
if (onHome) {
|
||||||
|
onHome();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setActiveItem(NAV.HOME);
|
setActiveItem(NAV.HOME);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -264,7 +264,15 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded })
|
|||||||
: setSelectedVideoId(video.video_id)
|
: setSelectedVideoId(video.video_id)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{video.result_movie_url ? (
|
{video.poster_url ? (
|
||||||
|
<img
|
||||||
|
src={video.poster_url}
|
||||||
|
alt={video.store_name}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
className="content-video-preview"
|
||||||
|
/>
|
||||||
|
) : video.result_movie_url ? (
|
||||||
<VideoPreviewCard
|
<VideoPreviewCard
|
||||||
src={video.result_movie_url}
|
src={video.result_movie_url}
|
||||||
className="content-video-preview"
|
className="content-video-preview"
|
||||||
|
|||||||
51
src/pages/Landing/DisplaySection.tsx
Executable file
51
src/pages/Landing/DisplaySection.tsx
Executable file
@ -0,0 +1,51 @@
|
|||||||
|
|
||||||
|
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;
|
||||||
202
src/pages/Landing/HeroSection.tsx
Executable file
202
src/pages/Landing/HeroSection.tsx
Executable file
@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
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;
|
||||||
70
src/pages/Landing/WelcomeSection.tsx
Executable file
70
src/pages/Landing/WelcomeSection.tsx
Executable file
@ -0,0 +1,70 @@
|
|||||||
|
|
||||||
|
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;
|
||||||
109
src/pages/Login/LoginSection.tsx
Executable file
109
src/pages/Login/LoginSection.tsx
Executable file
@ -0,0 +1,109 @@
|
|||||||
|
|
||||||
|
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;
|
||||||
@ -247,7 +247,7 @@ const SsulDetailContent: React.FC<SsulDetailContentProps> = ({
|
|||||||
if (!commentInput.trim() || commentSubmitting) return;
|
if (!commentInput.trim() || commentSubmitting) return;
|
||||||
setCommentSubmitting(true);
|
setCommentSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await postVideoComment(contentId, commentInput.trim(), commentNickname, undefined, 'ssul');
|
await postVideoComment(contentId, commentInput.trim(), undefined, 'ssul');
|
||||||
setCommentInput('');
|
setCommentInput('');
|
||||||
if (commentTextareaRef.current) {
|
if (commentTextareaRef.current) {
|
||||||
commentTextareaRef.current.style.height = 'auto';
|
commentTextareaRef.current.style.height = 'auto';
|
||||||
|
|||||||
@ -2886,6 +2886,7 @@
|
|||||||
.wizard-stepper {
|
.wizard-stepper {
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
margin-top: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wizard-stepper-node {
|
.wizard-stepper-node {
|
||||||
|
|||||||
@ -1145,6 +1145,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 모바일: 제목은 한 줄로 유지하고, 권장 수량/카운트는 그 아래 줄로 내린다 */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.asset-section-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-section-header-left {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-section-header-left .asset-section-title {
|
||||||
|
order: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-section-header-left .asset-section-count {
|
||||||
|
order: 2;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-section-header-left .asset-section-subtitle {
|
||||||
|
order: 3;
|
||||||
|
width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Asset Image List */
|
/* Asset Image List */
|
||||||
.asset-image-list {
|
.asset-image-list {
|
||||||
background-color: #002224;
|
background-color: #002224;
|
||||||
|
|||||||
@ -287,17 +287,22 @@ export interface VideoListItem {
|
|||||||
region: string;
|
region: string;
|
||||||
task_id: string;
|
task_id: string;
|
||||||
result_movie_url: string;
|
result_movie_url: string;
|
||||||
|
poster_url?: string | null;
|
||||||
thumbnail_url?: string;
|
thumbnail_url?: string;
|
||||||
|
title?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
hashtags?: string[] | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
like_count: number;
|
like_count: number;
|
||||||
comment_count: number;
|
comment_count: number;
|
||||||
is_liked_by_me?: boolean;
|
is_liked_by_me: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 비디오 상세 아이템
|
// 비디오 상세 아이템
|
||||||
export interface VideoDetailItem {
|
export interface VideoDetailItem {
|
||||||
video_id: number;
|
video_id: number;
|
||||||
result_movie_url: string;
|
result_movie_url: string;
|
||||||
|
poster_url?: string | null;
|
||||||
store_name: string;
|
store_name: string;
|
||||||
region: string;
|
region: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@ -320,6 +325,7 @@ export interface VideosListResponse {
|
|||||||
export interface CommentReply {
|
export interface CommentReply {
|
||||||
id: number;
|
id: number;
|
||||||
nickname: string;
|
nickname: string;
|
||||||
|
profile_image_url: string | null;
|
||||||
content: string | null;
|
content: string | null;
|
||||||
is_deleted: boolean;
|
is_deleted: boolean;
|
||||||
is_mine: boolean;
|
is_mine: boolean;
|
||||||
@ -330,6 +336,7 @@ export interface CommentReply {
|
|||||||
export interface CommentItem {
|
export interface CommentItem {
|
||||||
id: number;
|
id: number;
|
||||||
nickname: string;
|
nickname: string;
|
||||||
|
profile_image_url: string | null;
|
||||||
content: string | null;
|
content: string | null;
|
||||||
is_deleted: boolean;
|
is_deleted: boolean;
|
||||||
is_mine: boolean;
|
is_mine: boolean;
|
||||||
|
|||||||
@ -658,18 +658,17 @@ export async function getVideoComments(
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 댓글 작성
|
// 댓글 작성 (작성자 닉네임/프로필은 서버가 로그인된 카카오 정보로 채움)
|
||||||
export async function postVideoComment(
|
export async function postVideoComment(
|
||||||
videoId: string,
|
videoId: string,
|
||||||
content: string,
|
content: string,
|
||||||
nickname?: string,
|
|
||||||
parentId?: number,
|
parentId?: number,
|
||||||
contentType: ContentType = 'video'
|
contentType: ContentType = 'video'
|
||||||
): Promise<CommentItem> {
|
): Promise<CommentItem> {
|
||||||
const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}?type=${contentType}`, {
|
const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}?type=${contentType}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content, nickname: nickname || '익명', parent_id: parentId ?? null }),
|
body: JSON.stringify({ content, parent_id: parentId ?? null }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
39
src/utils/nativeShare.ts
Normal file
39
src/utils/nativeShare.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* 기기의 네이티브 공유 시트를 열고 요청을 처리했는지 반환합니다.
|
||||||
|
*
|
||||||
|
* 사용자가 공유 시트를 닫은 경우도 정상적으로 처리된 것으로 간주합니다.
|
||||||
|
*/
|
||||||
|
export async function tryNativeShare(data: ShareData): Promise<boolean> {
|
||||||
|
if (typeof navigator.share !== 'function') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.share(data);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** API 서버가 제공하는 영상별 Open Graph 공유 페이지 URL을 만듭니다. */
|
||||||
|
export function buildVideoShareUrl(apiBaseUrl: string, videoId: number | string): string {
|
||||||
|
return `${apiBaseUrl.replace(/\/$/, '')}/video/share/${encodeURIComponent(String(videoId))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 콘텐츠 종류별 공개 공유 URL. video.id 와 ssul_content.id 가 겹치므로 경로를 갈라야 한다. */
|
||||||
|
export function buildContentShareUrl(
|
||||||
|
apiBaseUrl: string,
|
||||||
|
contentId: number | string,
|
||||||
|
contentType: 'video' | 'ssul' = 'video',
|
||||||
|
): string {
|
||||||
|
if (contentType === 'ssul') {
|
||||||
|
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||||
|
return `${origin}/ssul/${encodeURIComponent(String(contentId))}`;
|
||||||
|
}
|
||||||
|
return buildVideoShareUrl(apiBaseUrl, contentId);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user